Улучшаем useReducer

от автора

С появлением useReducer и useContext управление app state стало намного удобнее, а также отпала необходимость в использовании Redux.

Когда я в первый раз отказался от Redux в пользу стандартного useReducer я ощутил нехватку некоторых полезных функций:

  • useSelector. Позволяет оптимизировать перерисовку компонент, которые используют useContext, с помощью memo.
  • Единственный dispatch. Упрощает обновление app state так как не нужно использовать отдельный dispatch каждого useReducer.
  • Cache. Не нужно заботиться о кэширование каждого useReducer.

Тогда я решил попробовать улучшить стандартный useReducer, добавив эти 3 функции. Эта идея превратилась в новую небольшую библиотеку, которую я назвал Flex Reducer.

Интересный факт!

Flex Reducer не использует ни useReducer ни useContext в своей реализации.

Посмотрим плюсы и минусы использования стандартных useReducer + useContext и Flex Reducer на примере типичного Todo App.

Для начала создадим главный файл, где отрисовывается дерево React.

// index.js import TodoApp from "./TodoApp";  const rootElement = document.getElementById("root"); ReactDOM.render(   <TodoApp />,   rootElement );

Замечание: Больше никаких combine reducers, create store и Provider. Ура! 🙂

Теперь напишем основной компонент Todo App, используя useReducer.

// TodoApp.js import { useReducer, createContext, useMemo } from 'react'; import AddTodo from './AddTodo'; import TodoList from './TodoList';  export const AppContext = createContext(null); const cache = {};  export default function TodoApp() {   const [state, dispatch] = useReducer(reducer, cache.state || initialState);   cache.state = state;   const actions = useMemo(() => ({     setInput: (value) => {       dispatch({         type: 'SET_INPUT',          payload: value       })     },     addTodo: ({ id, content }) => {       dispatch({         type: 'ADD_TODO',         payload: { id, content }       })     }   }), []);   return (     <AppContext.Provider value=[state, actions]>       <div className="todo-app">         <h1>{state.title}</h1>         <input value={state.input} onChange={e => actions.setInput(e.target.value)} />         <AddTodo />         <TodoList />       </div>     </AppContext>   ); }

Выглядит неплохо. Теперь то же самое, но используя Flex Reducer.

// TodoApp.js import { useFlexReducer, dispatch } from 'flex-reducer'; import AddTodo from './AddTodo'; import TodoList from './TodoList';  export const setInput = (value) => dispatch({   type: SET_INPUT,   payload: value }); export const addTodo = ({ id, content }) => dispatch({   type: ADD_TODO,   payload: { id, content } });  export default function TodoApp() {   const [state] = useFlexReducer('app', reducer, initialState);   return (     <div className="todo-app">       <h1>{state.title}</h1>       <input value={state.input} onChange={e => setInput(e.target.value)} />       <AddTodo />       <TodoList />     </div>   ); }

Выглядит приятнее, читаемость кода однозначно улучшилась.
Какие улучшения мы получили:

  • Не нужно использовать React Context.
  • Не нужно заботиться о кэшировании.
  • Можем объявлять actions где угодно так как есть глобальный dispatch.

Теперь давайте сравним как выглядит оптимизация re-renders на примере Add Todo button.
С использованием стандартных хуков.

// AddTodo.js import { useContext, memo } from 'react'; import { appContext } from './TodoApp';  const genId = () => Math.rand();  const AddTodo = memo(({ input, actions }) => {   function handleAddTodo() {     if (content) {       actions.addTodo({ id: genId(), content: input });       actions.setInput('');     }   }   return (     <button onClick={handleAddTodo}>       Add Todo     </button>   ); })  export default const MemoizedAddTodo = () => {   const [state, actions] = useContext(appContext);   return (     <AddTodo input={state.input} actions={actions} />   ); }

Мы не можем использовать useContext в AddTodo напрямую потому что это будет вызывать render на каждое изменение контекста в независимости от использования memo. Поэтому нам придется создать еще один компонент-обертку, в который вынесем useContext и будем передавать нужные данные через props.

Теперь попробуем оптимизацию с Flex Reducer.

// AddTodo.js import { useSelector } from 'flex-reducer'; import { addTodo, setInput } from "./TodoApp";  const genId = () => Math.rand();  export default const AddTodo = React.memo(() => {   const content = useSelector(state => state.app.input);   function handleAddTodo() {     if (content) {       addTodo({ id: genId(), content });       setInput('');     }   }   return (     <button onClick={handleAddTodo}>       Add Todo     </button>   ); })

Прекрасно. Не нужен никакой дополнительный компонент-обертка. Спасибо useSelector, который вызывает re-render только когда меняется input.

Однако, все в этом мире имеет свои плюсы и минусы, Flex Reducer не исключение.
Давайте сравним как он будет работать с remote data, которые загружаются декларативно, например с помощью react-query.

В случае со стандартным useReducer.

// TodoApp.js import { useReducer, createContext, useMemo } from 'react'; import { useQuery } from 'react-query'; import AddTodo from './AddTodo'; import TodoList from './TodoList';  export const AppContext = createContext(null); const cache = {};  export default function TodoApp() {   const [reducerState, dispatch] = useReducer(reducer, cache.state || initialState);   cache.state = reducerState;   const actions = useMemo(() => ({     setInput: (value) => {       dispatch({         type: 'SET_INPUT',          payload: value       })     },     addTodo: ({ id, content }) => {       dispatch({         type: 'ADD_TODO',         payload: { id, content }       })     }   }), []);    const todos = useQuery('todos', fetchTodoList);   const state = { ...reducerState, todos };    return (     <AppContext.Provider value=[state, actions]>       <div className="todo-app">         <h1>{state.title}</h1>         <input value={state.input} onChange={e => actions.setInput(e.target.value)} />         <AddTodo />         <TodoList />       </div>     </AppContext>   ); }

Неплохо. Просто и читаемо.

Попробуем то же самое с Flex Reducer.

// TodoApp.js import { useFlexReducer, dispatch } from 'flex-reducer'; import { useQuery } from 'react-query'; import AddTodo from './AddTodo'; import TodoList from './TodoList';  export const setInput = (value) => dispatch({   type: SET_INPUT,   payload: value }); export const addTodo = ({ id, content }) => dispatch({   type: ADD_TODO,   payload: { id, content } }); export const setTodos = (todos) => dispatch({   type: SET_TODOS,   payload: todos });  export default function TodoApp() {   const [state] = useFlexReducer('app', reducer, initialState);   const todos = useQuery('todos', fetchTodoList);   React.useEffect(() => {     setTodos(todos);   }, [todos]);    return (     <div className="todo-app">       <h1>{state.title}</h1>       <input value={state.input} onChange={e => setInput(e.target.value)} />       <AddTodo />       <TodoList />     </div>   ); }

Возникает проблема с лишней перерисовкой когда обновляется состояние редьюсера на каждое обновление todos query.

Заключение

Использование useReducer + useContext для управления app state вполне удобно. Но это требует внимательной "ручной" работы с контекстом и кэшем.
Flex Reducer берет это на себя, улучшает читаемость кода, облегчает написание оптимизации с memo и сокращает количество когда. Но появляются проблемы при работе с remote data декларативно (как с react-query).

Внимание!

Flex Reducer всего лишь эксперимент и не использовался в production.

Спасибо, что читали. Приветствуются любые мысли.

Рабочий пример Todo app можно найти тут.
Ссылка на репозиторий Flex Reducer

ссылка на оригинал статьи https://habr.com/ru/post/513548/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *