Доброго времени суток, друзья!
Представляю вашему вниманию первую десятку пользовательских хуков.
Оглавление
import React, { useState, useEffect, useRef } from "react";
// использование
function MyComponent({ obj }) {
const [state, setState] = useState();
// возвращаем старый объект, если свойство "id" не изменилось
const objFinal = useMemoCompare(obj, (prev, next) => {
return prev && prev.id === next.id;
});
// мы хотим запускать эффект при изменении objFinal
// если мы используем obj напрямую, без нашего хука, и obj технически будет
// новым объектом при каждом рендеринге, тогда эффект также будет срабатывать при каждом рендеринге
// что еще хуже, если наш эффект приводит к изменению состояния, это может закончиться бесконечным циклом
// запускается эффект -> изменение состояния влечет повторный рендеринг -> снова запускается эффект -> и т.д.
useEffect(() => {
// вызываем метод объекта и присваиваем результат состоянию
return objFinal.someMethod().then((value) => setState(value));
}, [objFinal]);
// почему нам не передать [obj.id] в качестве зависимости?
useEffect(() => {
// eslint-plugin-hooks справедливо решит, что obj не указан в массиве зависимостей
// и нам придется использовать eslint-disable-next-line для решения этой проблемы
// лучше просто получить ссылку на старый объект с помощью нашего хука
return obj.someMethod().then((value) => setState(value));
}, [obj.id]);
}
// хук
function useMemoCompare(next, compare) {
// ref для хранения предыдущего значения
const prevRef = useRef();
const prev = prevRef.current;
// передаем предыдущее и новое значения в функцию
// для определения их идентичности
const isEqual = compare(prev, next);
// если значения не равны, обновляем prevRef
// обновление осуществляется только в случае неравенства значений
// поэтому, если функция вернула true, хук возвращает старое значение
useEffect(() => {
if (!isEqual) {
prevRef.current = next;
}
});
// если значения равны, возвращаем старое значение
return isEqual ? prev : next;
}
import React, { useState, useEffect, useCallback } from 'react'
// использование
function App() {
const {execute, status, value, error } = useAsync(myFunction, false)
return (
<div>
{status === 'idle' && <div>Начните ваше путешествие с нажатия кнопки</div>}
{status === 'success' && <div>{value}</div>}
{status === 'error' && <div>{error}</div>}
<button onClick={execute} disabled={status === 'pending'}>
{status !== 'pending' ? 'Нажми меня' : 'Загрузка...'}
</button>
</div>
)
}
// асинхронная функция для тестирования хука
// успешно выполняется в 50% случаев
const myFunction = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
const random = Math.random() * 10
random <=5
? resolve('Выполнено успешно')
: reject('Произошла ошибка')
}, 2000)
})
}
// хук
const useAsync = (asyncFunction, immediate = true) => {
const [status, setStatus] = useState('idle')
const [value, setValue] = useState(null)
const [error, setError] = useState(null)
// функция "execute" оборачивает asyncFunction и
// обрабатывает настройку состояний для pending, value и error
// useCallback предотвращает вызов useEffect при каждом рендеринге
// useEffect вызывается только при изменении asyncFunction
const execute = useCallback(() => {
setStatus('pending')
setValue(null)
setError(null)
return asyncFunction()
.then(response => {
setValue(response)
setStatus('success')
})
.catch(error => {
setError(error)
setStatus('error')
})
}, [asyncFunction])
// вызываем execute для немедленного выполнения
// с другой стороны, execute может быть вызвана позже
// например, как обработчик нажатия кнопки
useEffect(() => {
if (immediate) {
execute()
}
}, [execute, immediate])
return { execute, status, value, error }
}
import Dashboard from "./Dahsboard.js";
import Loading from "./Loading.js";
import { useRequireAuth } from "./use-require-auth.js";
function DashboardPage(props) {
const auth = useRequireAuth();
// если значением auth является null (данные еще не получены)
// или false (пользователь вышел из учетной записи)
// показываем индикатор загрузки
if (!auth) {
return <Loading />;
}
return <Dashboard auth={auth} />;
}
// хук (use-require-auth.js)
import { useEffect } from "react";
import { useAuth } from "./use-auth.js";
import { useRouter } from "./use-router.js";
function useRequireAuth(redirectUrl = "./signup") {
const auth = useAuth();
const router = useRouter();
// если значением auth.user является false,
// значит, вход не выполнен, осуществляем перенаправление
useEffect(() => {
if (auth.user === false) {
router.push(redirectUrl);
}
}, [auth, router]);
return auth;
}
import { useMemo } from "react";
import {
useParams,
useLocation,
useHistory,
useRouterMatch,
} from "react-router-dom";
import queryString from "query-string";
// использование
function MyComponent() {
// получаем объект роутера
const router = useRouter();
// получаем значение строки запроса (?postId=123) или параметров запроса (/:postId)
console.log(router.query.postId);
// получаем название текущего пути
console.log(router.pathname);
// реализуем навигацию с помощью router.push()
return <button onClick={(e) => router.push("./about")}>About</button>;
}
// хук
export function useRouter() {
const params = useParams();
const location = useLocation();
const history = useHistory();
const match = useRouterMatch();
// возвращаем наш объект роутера
// запоминаем его для того, чтобы новый объект возвращался только при наличии изменений
return useMemo(() => {
return {
// для удобства определяем push(), replace() и pathname на верхнем уровне
push: history.push,
replace: history.replace,
pathname: location.pathname,
// объединяем параметры и преобразуем строку запроса в простой объект "query"
// для того, чтобы они были взаимозаменяемыми
// пример: /:topic?sort=popular -> { topic: 'react', sort: 'popular' }
query: {
...queryString.parse(location.search), // преобразуем строку в объект
...params,
},
// добавляем объекты "match", "location" и "history"
// в качестве дополнительного функционала React Router
match,
location,
history,
};
}, [params, match, location, history]);
}
// глобальный компонент App
import React from "react";
import { ProvideAuth } from "./use-auth.js";
function App(props) {
return (
<ProvideAuth>
{/*
здесь находятся компоненты роутера, которые зависят от структуры приложения
при использовании Next.js, это будет выглядеть так: /pages/_app.js
*/}
</ProvideAuth>
);
}
// любой компонент, которому требуется состояние аутентификации
import React from "react";
import { useAuth } from "./use-auth.js";
function NavBar(props) {
// получаем состояние auth и осуществляем перерисовку при его изменении
const auth = useAuth();
return (
<NavbarContainer>
<Logo />
<Menu>
<Link to="/about">About</Link>
<Link to="/contact">Contact</Link>
{auth.user ? (
<Fragment>
<Link to="/account">Account ({auth.user.email})</Link>
<Button onClick={() => auth.signout()}>Signout</Button>
</Fragment>
) : (
<Link to="/signin">Signin</Link>
)}
</Menu>
</NavbarContainer>
);
}
// хук (use-auth.js)
import React, { useState, useEffect, useContext, createContext } from "react";
import * as firebase from "firebase/app";
import "firebase/auth";
// добавлем данные для Firebase
firebase.initializeApp({
apiKey: "",
authDomain: "",
projectId: "",
appID: "",
});
const authContext = createContext();
// компонент Provider, оборачивающий приложение и делающий объект "auth"
// доступным для любого дочернего компонента, вызывающего useAuth
export const useAuth = () => {
return useContext(authContext);
};
// хук для дочерних компонентов для получения объекта "auth"
// и повторного рендеринга при его изменении
export const useAuth = () => {
return useContext(authContext);
};
// хук провайдера, создающий объект "auth" и обрабатывающий его состояние
function useProviderAuth() {
const [user, setUser] = useState(null);
// оборачиваем любые методы Firebase, неоходимые для сохранения
// состояния пользователя
const signin = (email, password) => {
return firebase
.auth()
.signInWithEmailAndPassword(email, password)
.then((response) => {
setUser(response.user);
return response.user;
});
};
const signup = (email, password) => {
return firebase
.auth()
.createUserWithEmailAndPassword(email, password)
.then((response) => {
setUser(response.user);
return response.user;
});
};
const signout = () => {
return firebase
.auth()
.signOut()
.then(() => {
setUser(false);
});
};
const sendPasswordResetEmail = (email) => {
return firebase
.auth()
.sendPasswordResetEmail(email)
.then(() => true);
};
const confirmPasswordReset = (code, password) => {
return firebase
.auth()
.confirmPasswordReset(code, password)
.then(() => true);
};
// регистрируем пользователя при монтировании
// установка состояния в колбэке приводит к тому
// что любой компонент, использующий хук
// перерисовывается с учетом последнего объекта "auth"
useEffect(() => {
const unsubscribe = firebase.auth().onAuthStateChange((user) => {
if (user) {
setUser(user);
} else {
setUser(false);
}
});
// отписываемся от пользователя
return () => unsubscribe();
}, []);
// возвращаем объект "user" и методы аутентификации
return {
user,
signin,
signup,
signout,
sendPasswordResetEmail,
confirmPasswordReset,
};
}
import { useState, useRef, useEffect, useCallback } from "react";
// использование
function App() {
// состояние для хранения координат курсора
const [coords, setCoords] = useState({ x: 0, y: 0 });
// обработчик событий обернут в useCallback,
// поэтому ссылка никогда не изменится
const handler = useCallback(
({ clientX, clientY }) => {
// обновляем координаты
setCoords({ x: clientX, y: clientY });
},
[setCoords]
);
// добавляем обработчик с помощью нашего хука
useEventListener("mousemove", handler);
return <h1>Позиция курсора: ({(coords.x, coords.y)})</h1>;
}
// хук
function useEventListener(eventName, handler, element = window) {
// создаем ссылку, хранящую обработчик
const saveHandler = useRef();
// обновляем ref.current при изменении обработчика
// это позволяет нашему эффекту всегда иметь дело с последним обработчиком
// без необходимости передавать ему массив зависимостей
// что запускает эффект при каждом рендеринге
useEffect(() => {
saveHandler.current = handler;
}, [handler]);
useEffect(
() => {
// проверяем поддержку addEventListener
const isSupported = element && element.addEventListener;
if (!isSupported) return;
// создаем обработчик событий, который вызывает обработчик, сохраненный в ref
const eventListener = (event) => saveHandler.current(event);
// добавляем обработчик событий
element.addEventListener(eventName, eventListener);
// удаляем обработчик событий на выходе
return () => {
element.removeEventListener(eventName, eventListener);
};
},
[eventName, element] // перезапускаем только при изменении элемента
);
}
import { useState, useEffect, useRef } from "react";
// представим, что <Counter> является дорогим для повторного рендеринга
// поэтому мы обернули его в React.memo, но проблемы остались
// мы добавили useWhyDidYouUpdate для прояснения ситуации
const Counter = React.memo((props) => {
useWhyDidYouUpdate("Counter", props);
return <div style={props.style}>{props.count}</div>;
});
function App() {
const [count, setCount] = useState(0);
const [userId, setUserId] = useState(0);
// хук показывает, что объект, предназначенный для стилизации <Counter>
// меняется при каждом рендеринге, даже когда мы меняем только состояние userId
// нажимая кнопку "switch user". Разумеется, это происходит потому
// что объект заново создается при каждом рендеринге
// благодаря хуку мы поняли, что нам следует поместить этот объект
// за пределами тела компонента
const counterStyle = {
fontSize: "3rem",
color: "red",
};
}
return (
<div>
<div className="counter">
<Counter count={count} style={counterStyle} />
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
<div className="user">
<img src={`http://i.pravatar.cc/80?img=${userId}`} />
<button onClick={() => setUserId(userId + 1)}>Switch User</button>
</div>
</div>
);
// хук
function useWhyDidYouUpdate(name, props) {
// создаем неизменяемый объект "ref" для хранения пропсов
// чтобы иметь возможность сравнить пропсы при следующем запуске
const prevProps = useRef();
useEffect(() => {
if (prevProps.current) {
// получаем ключи предыдущего и текущего пропсов
const allKeys = Object.keys({ ...prevProps.current, ...props });
// используем этот объект для отслеживания изменений пропсов
const changesObj = {};
// перебираем ключи
allKeys.forEach((key) => {
// если предыдущий отличается от текущего
if (prevProps.current[key] !== props[key]) {
// добавлем его в changesObj
changesObj[key] = {
from: prevProps.current[key],
to: props[key],
};
}
});
// если в changesObj что-то есть, выводим сообщение в консоль
if (object.keys(changesObj).length) {
console.log("why-did-you-update", name, changesObj);
}
}
// наконец, обновляем prevProps текущими пропсами для следующего вызова хука
prevProps.current = props;
});
}
function App() {
const [darkMode, setDarkMode] = useDarkMode();
return (
<div>
<div className="navbar">
<Toggle darkMode={darkMode} setDarkMode={setDarkMode} />
</div>
<Content />
</div>
);
}
// хук
function useDarkMode() {
// используем хук "useLocalStorage" для сохранения состояния
const [enabledState, setEnableState] = useLocalStorage("dark-mode-enabled");
// проверяем предпочтения пользователя относительно цветовой схемы
// в хуке "usePrefersDarkMode" используется хук "useMedia"
const prefersDarkMode = usePrefersDarkMode();
// если enabledState определена, используем ее, иначе, используем prefersDarkMode
const enabled =
typeof enabledState !== "undefined" ? enabledState : prefersDarkMode;
// запускаем эффект добавления/удаления класса
useEffect(
() => {
const className = "dark-mode";
const element = window.document.body;
if (enabled) {
element.classList.add(className);
} else {
element.classList.remove(className);
}
},
[enabled] // перезапускаем эффект только при изменении enabled
);
// возвращаем установленный режим и сеттер
return [enabled, setEnableState];
}
// используем хук "useMedia" для определения пользовательской схемы
// интерфейс этого хука выглядит немного странно, но это объясняется тем,
// что он предназначен для поддержки нескольких медиа-запросов и возвращаемых значений
// благодаря композиции мы можем скрыть сложные детали реализации
function usePrefersDarkMode() {
return useMedia(["(prefers-color-scheme: dark)"], [true], false);
}
import { useState, useEffect } from "react";
function App() {
const columnCount = useMedia(
// медиа-запросы
["(min-width: 1500px)", "(min-width: 1000px)", "(min-width: 600px)"],
// количество колонок зависит от запроса
[5, 4, 3],
// количество колонок по умолчанию
2
);
// создаем массив с высотой колонок (начиная с 0)
let columnHeight = new Array(columnCount).fill(0);
// создаем массив массивов, содержащих каждую колонку
let columns = new Array(columnCount).fill().map(() => []);
data.forEach((item) => {
// получает индекс самой короткой колонки
const shortColumntIndex = columnHeight.indexOf(Math.min(...columnHeight));
// добавляем элемент
columns[shortColumntIndex].push(item);
// обновляем высоту
columnHeight[shortColumntIndex] += item.height;
});
// рендерим колонки и элементы
return (
<div className="App">
<div className="columns is-mobile">
{columns.map((column) => (
<div className="column">
{column.map((item) => (
<div
className="image-container"
style={{
// размер изображения определяется его aspect ratio
paddingTop: (item.height / item.width) * 100 + "%",
}}
>
<img src={item.image} alt="" />
</div>
))}
</div>
))}
</div>
</div>
);
}
// хук
function useMedia(queries, values, defaultValue) {
// массив с медиа-запросами
const mediaQueryList = queries.map((q) => window.matchMedia(q));
// функция получения значения на основе запроса
const getValue = () => {
// получаем индекс первого совпавшего запроса
const index = mediaQueryList.findIndex((mql) => mql.matches);
// возвращаем соответствующее значение или значение по умолчанию
return typeof values[index] !== "undefined"
? values[index]
: defaultValue;
};
// состояние и сеттер для совпавшего значения
const [value, setValue] = useState(getValue);
useEffect(
() => {
// колбэк обработчика событий
// обратите внимание: определяя getValue за пределами useEffect, мы обеспечиваем
// соответствие между текущими значениями и аргументами хука
// поскольку колбэк создается только один раз при монтировании
const handler = () => setValue(getValue);
// регистрируем обработчик для каждого медиа-запроса
mediaQueryList.forEach((mql) => mql.addEventListener(handler));
// удаляем обработчики на выходе
return () =>
mediaQueryList.forEach((mql) => mql.removeEventListener(handler));
},
[] // пустой массив обеспечивает запуск эффекта только при монтировании и размонтировании
);
return value;
}
import { useState } from "react";
// использование
function App() {
// аналогично useState, но первым аргументом является ключ значения, хранящегося в локальном хранилище
const [name, setName] = useLocalStorage("name", "Igor");
return (
<div>
<input
type="text"
placeholder="Enter your name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
);
}
// хук
function useLocalStorage(key, initialValue) {
// состояние для хранения значения
// передаем функцию инициализации useState для однократного выполнения
const [storedValue, setStoredValue] = useState(() => {
try {
// получаем значение из локального хранилища по ключу
const item = window.localStorage.getItem(key);
// разбираем полученное значение или возвращаем initialValue
return item ? JSON.parse(item) : initialValue;
} catch (error) {
// если возникла ошибка, также возвращаем начальное значение
console.error(error);
return initialValue;
}
});
// возвращаем обернутую версию сеттера useState,
// которая помещает новое значение в локальное хранилище
const setValue = (value) => {
try {
// значение может быть функцией
const valueToStore =
value instanceof Function ? value(storedValue) : value;
// сохраняем состояние
setStoredValue(valueToStore);
// помещаем его в локальное хранилище
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
// более продвинутая реализация может предполагать обработку ошибок в зависимости от вида ошибки
console.error(error);
}
};
return [storedValue, setValue];
}
К сожалению, не доступен сервер mySQL