diff --git a/lessons/lesson32/code/package.json b/lessons/lesson32/code/package.json
new file mode 100644
index 0000000..b685e29
--- /dev/null
+++ b/lessons/lesson32/code/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "react-typescript",
+ "version": "1.0.0",
+ "description": "React and TypeScript example starter project",
+ "keywords": [
+ "typescript",
+ "react",
+ "starter"
+ ],
+ "main": "src/index.tsx",
+ "dependencies": {
+ "loader-utils": "3.2.1",
+ "react": "18.2.0",
+ "react-dom": "18.2.0",
+ "react-scripts": "5.0.1",
+ "@testing-library/react": "16.3.0",
+ "@testing-library/dom": "10.4.0"
+ },
+ "devDependencies": {
+ "@types/react": "18.2.38",
+ "@types/react-dom": "18.2.15",
+ "typescript": "4.4.4"
+ },
+ "scripts": {
+ "start": "react-scripts start",
+ "build": "react-scripts build",
+ "test": "react-scripts test --env=jsdom",
+ "eject": "react-scripts eject"
+ },
+ "browserslist": [
+ ">0.2%",
+ "not dead",
+ "not ie <= 11",
+ "not op_mini all"
+ ]
+}
diff --git a/lessons/lesson32/code/public/index.html b/lessons/lesson32/code/public/index.html
new file mode 100644
index 0000000..475209a
--- /dev/null
+++ b/lessons/lesson32/code/public/index.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+ React App
+
+
+
+
+
+
+
+
diff --git a/lessons/lesson32/code/src/App.tsx b/lessons/lesson32/code/src/App.tsx
new file mode 100644
index 0000000..dbc5c59
--- /dev/null
+++ b/lessons/lesson32/code/src/App.tsx
@@ -0,0 +1,10 @@
+import "./styles.css";
+
+export default function App() {
+ return (
+
+
Hello CodeSandbox
+ Start editing to see some magic happen!
+
+ );
+}
diff --git a/lessons/lesson32/code/src/hooks/useDebounce.test.tsx b/lessons/lesson32/code/src/hooks/useDebounce.test.tsx
new file mode 100644
index 0000000..8559dab
--- /dev/null
+++ b/lessons/lesson32/code/src/hooks/useDebounce.test.tsx
@@ -0,0 +1,48 @@
+import { renderHook, act } from "@testing-library/react";
+import { useDebounce } from "./useDebounce";
+
+// Используем фейковые таймеры Jest для управления временем в тестах
+jest.useFakeTimers();
+
+describe("useDebounce", () => {
+ it("должен возвращать начальное значение немедленно", () => {
+ const { result } = renderHook(() => useDebounce("initial", 500));
+ expect(result.current).toBe("initial");
+ });
+
+ it("не должен обновлять значение до истечения задержки", () => {
+ const { result, rerender } = renderHook(
+ ({ value, delay }) => useDebounce(value, delay),
+ {
+ initialProps: { value: "a", delay: 500 },
+ }
+ );
+
+ rerender({ value: "b", delay: 500 });
+
+ expect(result.current).toBe("a");
+
+ act(() => {
+ jest.advanceTimersByTime(499);
+ });
+
+ expect(result.current).toBe("a");
+ });
+
+ it("должен обновить значение после истечения задержки", () => {
+ const { result, rerender } = renderHook(
+ ({ value, delay }) => useDebounce(value, delay),
+ {
+ initialProps: { value: "a", delay: 500 },
+ }
+ );
+
+ rerender({ value: "b", delay: 500 });
+
+ act(() => {
+ jest.advanceTimersByTime(500);
+ });
+
+ expect(result.current).toBe("b");
+ });
+});
diff --git a/lessons/lesson32/code/src/hooks/useDebounce.tsx b/lessons/lesson32/code/src/hooks/useDebounce.tsx
new file mode 100644
index 0000000..cfdb327
--- /dev/null
+++ b/lessons/lesson32/code/src/hooks/useDebounce.tsx
@@ -0,0 +1,21 @@
+import { useState, useEffect } from "react";
+
+/**
+ * Хук, который возвращает значение с задержкой.
+ * @param {*} value - Значение, которое нужно "отложить".
+ * @param {number} delay - Задержка в миллисекундах.
+ * @returns {*} - "Отложенное" значение.
+ */
+export function useDebounce(value, delay) {
+ // TODO: Реализуйте хук.
+ // 1. Создайте состояние `debouncedValue` для хранения "отложенного" значения.
+ // 2. Используйте `useEffect` для установки таймера (`setTimeout`).
+ // - Эффект должен срабатывать, когда `value` или `delay` изменяются.
+ // - Внутри таймера, по истечении `delay`, обновите `debouncedValue` на текущее `value`.
+ // 3. Не забудьте про функцию очистки в `useEffect`! Она должна отменять предыдущий
+ // таймер (`clearTimeout`), если `value` изменилось до того, как таймер сработал.
+ // 4. Верните `debouncedValue`.
+
+ // Заглушка
+ return value;
+}
diff --git a/lessons/lesson32/code/src/hooks/useDocumentTitle.test.tsx b/lessons/lesson32/code/src/hooks/useDocumentTitle.test.tsx
new file mode 100644
index 0000000..5266c13
--- /dev/null
+++ b/lessons/lesson32/code/src/hooks/useDocumentTitle.test.tsx
@@ -0,0 +1,38 @@
+import { renderHook } from "@testing-library/react";
+import { useDocumentTitle } from "./useDocumentTitle";
+
+describe("useDocumentTitle", () => {
+ const originalTitle = document.title;
+
+ afterEach(() => {
+ document.title = originalTitle;
+ });
+
+ it("должен устанавливать заголовок документа", () => {
+ renderHook(() => useDocumentTitle("Новый заголовок"));
+ expect(document.title).toBe("Новый заголовок");
+ });
+
+ it("должен обновлять заголовок документа при изменении", () => {
+ const { rerender } = renderHook(({ title }) => useDocumentTitle(title), {
+ initialProps: { title: "Первый заголовок" },
+ });
+
+ expect(document.title).toBe("Первый заголовок");
+
+ rerender({ title: "Второй заголовок" });
+ expect(document.title).toBe("Второй заголовок");
+ });
+
+ it("должен (опционально) восстанавливать исходный заголовок при размонтировании", () => {
+ const { unmount } = renderHook(() =>
+ useDocumentTitle("Временный заголовок")
+ );
+
+ expect(document.title).toBe("Временный заголовок");
+
+ unmount();
+
+ expect(document.title).toBe(originalTitle);
+ });
+});
diff --git a/lessons/lesson32/code/src/hooks/useDocumentTitle.tsx b/lessons/lesson32/code/src/hooks/useDocumentTitle.tsx
new file mode 100644
index 0000000..8289d03
--- /dev/null
+++ b/lessons/lesson32/code/src/hooks/useDocumentTitle.tsx
@@ -0,0 +1,14 @@
+import { useEffect } from "react";
+
+/**
+ * Хук для динамического изменения заголовка документа (вкладки браузера).
+ * @param {string} title - Новый заголовок.
+ */
+export function useDocumentTitle(title) {
+ // TODO: Реализуйте хук.
+ // 1. Используйте `useEffect` для обновления `document.title`.
+ // - Эффект должен применяться каждый раз, когда изменяется `title`.
+ // 2. (Опционально, для лучшей практики) Добавьте функцию очистки,
+ // которая будет возвращать исходный заголовок документа,
+ // когда компонент, использующий хук, размонтируется.
+}
diff --git a/lessons/lesson32/code/src/hooks/useInput.test.tsx b/lessons/lesson32/code/src/hooks/useInput.test.tsx
new file mode 100644
index 0000000..c74f2d1
--- /dev/null
+++ b/lessons/lesson32/code/src/hooks/useInput.test.tsx
@@ -0,0 +1,20 @@
+import { renderHook, act } from "@testing-library/react";
+import { useInput } from "./useInput";
+
+describe("useInput", () => {
+ it("должен устанавливать начальное значение", () => {
+ const { result } = renderHook(() => useInput("начальное значение"));
+ expect(result.current.value).toBe("начальное значение");
+ });
+
+ it("должен обновлять значение при вызове onChange", () => {
+ const { result } = renderHook(() => useInput(""));
+
+ // act() гарантирует, что все обновления состояния будут обработаны
+ act(() => {
+ result.current.onChange({ target: { value: "новое значение" } });
+ });
+
+ expect(result.current.value).toBe("новое значение");
+ });
+});
diff --git a/lessons/lesson32/code/src/hooks/useInput.tsx b/lessons/lesson32/code/src/hooks/useInput.tsx
new file mode 100644
index 0000000..c90fab1
--- /dev/null
+++ b/lessons/lesson32/code/src/hooks/useInput.tsx
@@ -0,0 +1,12 @@
+import { useState } from "react";
+
+/**
+ * @param {string} initialValue - Начальное значение поля.
+ * @returns {{value: string, onChange: function}} - Объект со значением и обработчиком.
+ */
+export function useInput(initialValue = "") {
+ // TODO: Реализуйте хук.
+ // 1. Создайте состояние для хранения значения поля с помощью useState.
+ // 2. Создайте функцию-обработчик onChange, которая будет обновлять это состояние, получая значение из event.target.value.
+ // 3. Верните объект, содержащий текущее значение (value) и обработчик (onChange).
+}
diff --git a/lessons/lesson32/code/src/hooks/useLocalStorage.test.tsx b/lessons/lesson32/code/src/hooks/useLocalStorage.test.tsx
new file mode 100644
index 0000000..84935f2
--- /dev/null
+++ b/lessons/lesson32/code/src/hooks/useLocalStorage.test.tsx
@@ -0,0 +1,36 @@
+import { renderHook, act } from "@testing-library/react";
+import { useLocalStorage } from "./useLocalStorage";
+
+const TEST_KEY = "test-key";
+
+beforeEach(() => {
+ window.localStorage.clear();
+});
+
+describe("useLocalStorage", () => {
+ it("должен возвращать initialValue, если в localStorage пусто", () => {
+ const { result } = renderHook(() => useLocalStorage(TEST_KEY, "default"));
+ expect(result.current[0]).toBe("default");
+ });
+
+ it("должен читать существующее значение из localStorage", () => {
+ window.localStorage.setItem(TEST_KEY, JSON.stringify("stored value"));
+
+ const { result } = renderHook(() => useLocalStorage(TEST_KEY, "default"));
+ expect(result.current[0]).toBe("stored value");
+ });
+
+ it("должен обновлять значение и записывать его в localStorage", () => {
+ const { result } = renderHook(() => useLocalStorage(TEST_KEY, ""));
+
+ act(() => {
+ const setValue = result.current[1];
+ setValue("new value");
+ });
+
+ expect(result.current[0]).toBe("new value");
+
+ const storedValue = window.localStorage.getItem(TEST_KEY);
+ expect(JSON.parse(storedValue)).toBe("new value");
+ });
+});
diff --git a/lessons/lesson32/code/src/hooks/useLocalStorage.tsx b/lessons/lesson32/code/src/hooks/useLocalStorage.tsx
new file mode 100644
index 0000000..f91697d
--- /dev/null
+++ b/lessons/lesson32/code/src/hooks/useLocalStorage.tsx
@@ -0,0 +1,18 @@
+import { useState, useEffect } from "react";
+
+/**
+ * @param {string} key - Ключ для хранения в localStorage.
+ * @param {*} initialValue - Начальное значение.
+ * @returns {[*, function]} - Массив, аналогичный результату useState.
+ */
+export function useLocalStorage(key, initialValue) {
+ // TODO: Реализуйте хук.
+ // 1. В useState используйте функцию для ленивой инициализации, чтобы чтение из localStorage происходило только один раз.
+ // - Внутри этой функции попробуйте прочитать значение из localStorage по ключу (`window.localStorage.getItem(key)`).
+ // - Если там что-то есть, используйте `JSON.parse()` для этого значения и верните его.
+ // - Если там пусто или произошла ошибка (используйте try...catch), верните `initialValue`.
+ // 2. Используйте `useEffect` для сохранения нового значения в localStorage при его изменении.
+ // - Эффект должен срабатывать каждый раз, когда меняется `key` или `value`.
+ // - Внутри эффекта используйте `JSON.stringify()` для сохранения значения в localStorage.
+ // 3. Верните массив `[value, setValue]`, как это делает `useState`.
+}
diff --git a/lessons/lesson32/code/src/index.tsx b/lessons/lesson32/code/src/index.tsx
new file mode 100644
index 0000000..80f59d4
--- /dev/null
+++ b/lessons/lesson32/code/src/index.tsx
@@ -0,0 +1,12 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+
+const rootElement = document.getElementById("root")!;
+const root = ReactDOM.createRoot(rootElement);
+
+root.render(
+
+
+
+);
diff --git a/lessons/lesson32/code/src/styles.css b/lessons/lesson32/code/src/styles.css
new file mode 100644
index 0000000..59b0604
--- /dev/null
+++ b/lessons/lesson32/code/src/styles.css
@@ -0,0 +1,4 @@
+.App {
+ font-family: sans-serif;
+ text-align: center;
+}
diff --git a/lessons/lesson32/code/tsconfig.json b/lessons/lesson32/code/tsconfig.json
new file mode 100644
index 0000000..41b91bb
--- /dev/null
+++ b/lessons/lesson32/code/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "include": ["./src/**/*"],
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "lib": ["dom", "es2015"],
+ "jsx": "react-jsx"
+ }
+}
diff --git a/lessons/lesson32/lesson.md b/lessons/lesson32/lesson.md
index acdc2f5..eb66b0d 100644
--- a/lessons/lesson32/lesson.md
+++ b/lessons/lesson32/lesson.md
@@ -1 +1,323 @@
-# Lesson 32
+---
+title: Занятие 32
+description: Переиспользование кода с кастомными хуками
+---
+
+# OTUS
+
+## Javascript Basic
+
+
+
+### Вопросы?
+
+
+
+### Переиспользование кода с кастомными хуками
+
+
+
+Проблема дублирования логики
+
+```jsx
+// ComponentOne.jsx
+const [isHovered, setIsHovered] = useState(false);
+const handleMouseEnter = () => setIsHovered(true);
+const handleMouseLeave = () => setIsHovered(false);
+
+ ...
+
;
+```
+
+```jsx
+// ComponentTwo.jsx
+const [isHovered, setIsHovered] = useState(false);
+const handleMouseEnter = () => setIsHovered(true);
+const handleMouseLeave = () => setIsHovered(false);
+
+ ...
+
;
+```
+
+
+
+Логика полностью идентична. Как ее переиспользовать?
+
+
+
+Раньше для этого использовали:
+
+- HOC (Higher-Order Components) - Компоненты высшего порядка.
+- Render Props.
+
+
+
+HOC
+
+```jsx
+function withHover(WrappedComponent) {
+ return function (props) {
+ const [isHovered, setIsHovered] = useState(false);
+ const handleMouseEnter = () => setIsHovered(true);
+ const handleMouseLeave = () => setIsHovered(false);
+
+ return (
+
+
+
+ );
+ };
+}
+
+function MyComponent({ isHovered }) {
+ return {isHovered ? "На меня навели курсор!" : "Наведите курсор"}
;
+}
+
+export default withHover(MyComponent);
+```
+
+
+
+Render Props
+
+```jsx
+function Hover({ children }) {
+ const [isHovered, setIsHovered] = useState(false);
+ const handleMouseEnter = () => setIsHovered(true);
+ const handleMouseLeave = () => setIsHovered(false);
+
+ return (
+
+ {children(isHovered)}
+
+ );
+}
+
+function App() {
+ return (
+
+ {(isHovered) => (
+ {isHovered ? "На меня навели курсор!" : "Наведите курсор"}
+ )}
+
+ );
+}
+```
+
+
+
+Оба подхода рабочие, но имеют недостатки:
+
+- "Wrapper Hell": Дерево компонентов разрастается за счет оберток.
+- Неявные зависимости: Сложно понять, откуда приходят props.
+- Конфликты имен: Props от разных HOC могут перезаписывать друг друга.
+
+
+
+Хуки предложили более элегантное решение.
+
+
+
+Что такое кастомные хуки?
+
+Кастомный хук — это JavaScript-функция, имя которой начинается с use, и которая может вызывать другие хуки.
+
+Это не часть React API. Это соглашение, которое позволяет извлекать логику компонента в переиспользуемые функции.
+
+
+
+Зачем нужны кастомные хуки?
+
+- Убираем дублирование логики
+- Разделяем бизнес-логику и UI
+- Повышаем читаемость и тестируемость
+
+
+
+Правила хуков все еще действуют!
+
+Вызывайте хуки только на верхнем уровне вашей React-функции.
+
+Вызывайте хуки только из React-компонентов или из других кастомных хуков.
+
+
+
+хук useToggle
+
+
+
+Компонент с логикой внутри:
+
+```jsx
+import { useState } from "react";
+
+function ToggleComponent() {
+ const [isOn, setIsOn] = useState(false);
+ const toggle = () => setIsOn((prevIsOn) => !prevIsOn);
+
+ return ;
+}
+```
+
+
+
+Извлекаем логику в хук:
+
+```jsx
+import { useState, useCallback } from "react";
+
+export function useToggle(initialState = false) {
+ const [state, setState] = useState(initialState);
+ const toggle = useCallback(() => setState((prevState) => !prevState), []);
+
+ return [state, toggle];
+}
+```
+
+
+
+Используем наш новый хук:
+
+```jsx
+import { useToggle } from "../hooks/useToggle";
+
+function ToggleComponent() {
+ const [isOn, toggle] = useToggle(false);
+
+ return ;
+}
+```
+
+
+
+Теперь useToggle можно использовать в любом компоненте!
+
+
+
+хук useRequest
+
+```jsx
+function UserProfile() {
+ const [user, setUser] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ fetch("[https://api.example.com/user/1](https://api.example.com/user/1)")
+ .then((res) => res.json())
+ .then((data) => setUser(data))
+ .catch((err) => setError(err))
+ .finally(() => setIsLoading(false));
+ }, []);
+ // ... рендер состояний
+}
+```
+
+
+
+Создаем хук useRequest
+
+```jsx
+Копировать код
+import { useState, useEffect } from 'react';
+
+export function useRequest(url) {
+ const [data, setData] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!url) return;
+
+ setIsLoading(true);
+ fetch(url)
+ .then(res => res.json())
+ .then(data => setData(data))
+ .catch(err => setError(err))
+ .finally(() => setIsLoading(false));
+ }, [url]);
+
+ return { data, isLoading, error };
+}
+```
+
+
+
+Используем useRequest в компоненте:
+
+```jsx
+import { useRequest } from "../hooks/useRequest";
+
+function UserProfile({ userId }) {
+ const {
+ data: user,
+ isLoading,
+ error,
+ } = useRequest(`https://api.example.com/user/${userId}`);
+
+ if (isLoading) return Загрузка...
;
+ if (error) return Ошибка: {error.message}
;
+
+ return Имя пользователя: {user?.name}
;
+}
+```
+
+
+
+хук useInput
+
+```jsx
+Копировать код
+function useInput(initial = "") {
+ const [value, setValue] = useState(initial);
+ const onChange = e => setValue(e.target.value);
+ return { value, onChange };
+}
+```
+
+```jsx
+function MyForm() {
+ const props = useInput();
+ return ;
+}
+```
+
+
+
+хук useLocalStorage
+
+```jsx
+function useLocalStorage(key, initialValue) {
+ const [value, setValue] = useState(
+ () => JSON.parse(localStorage.getItem(key)) ?? initialValue
+ );
+
+ useEffect(() => {
+ localStorage.setItem(key, JSON.stringify(value));
+ }, [key, value]);
+
+ return [value, setValue];
+}
+```
+
+
+
+Лучшие практики
+
+- Имя должно начинаться с use
+- Фокус на одной задаче. Не делайте один хук, который делает всё. Лучше несколько маленьких, которые можно комбинировать.
+- Хук — чистая функция (без сайд-эффектов вне React API)
+- Логику можно комбинировать: хуки вызывают хуки
+- Выносите только повторяющийся код
+
+
+
+[Практика](https://codesandbox.io/p/sandbox/t6st27)
+
+
+
+Дополнительные материалы
+
+- [Официальная документация по хукам](https://react.dev/reference/react/hooks)
+- [Создание своего хука](https://react.dev/learn/reusing-logic-with-custom-hooks)
+- [Коллекция полезных кастомных хуков](https://usehooks.com/)