Русский
Русский
English
Статистика
Реклама

Usereducer

Из песочницы Todolist на React Hooks TypeScript от сборки до тестирования

13.07.2020 00:19:13 | Автор: admin
Начиная с версии 16.9, в библиотеке React JS доступен новый функционал хуки. Они дают возможность использовать состояние и другие функции React, освобождая от необходимости писать класс. Использование функциональных компонентов совместно с хуками позволяет разработать полноценное клиентское приложение.

Предлагаю рассмотреть создание версии Todolist приложения на React Hooks с использованием TypeScript.

Сборка


Структура проекта следующая:

src
| components
| index.html
| index.tsx
package.json
tsconfig.json
webpack.config.json

Файл package.json:
{  "name": "todo-react-typescript",  "version": "1.0.0",  "description": "",  "main": "index.tsx",  "scripts": {    "start": "webpack-dev-server --port 3000 --mode development --open --hot",    "build": "webpack --mode production"  },  "author": "",  "license": "ISC",  "devDependencies": {    "ts-loader": "^5.2.1",    "html-webpack-plugin": "^3.2.0",    "typescript": "^3.8.2",    "webpack": "^4.41.6",    "webpack-cli": "^3.3.11",    "webpack-dev-server": "^3.10.3"  },  "dependencies": {    "@types/react": "^16.9.23",    "@types/react-dom": "^16.9.5",    "react": "^16.12.0",    "react-dom": "^16.12.0"  }}

Для поддержки TypeScript, помимо пакета typescript, необходим ts-loader, скомпилирующий исходные tsx-файлы в js-код, а также пакеты со специальными типами данных для React @types/react и @types/react-dom. Дополнительно ставим html-webpack-plugin, он обеспечит корректную работу dev-сервера при отсутствии index.html файла в корне проекта, и создаст этот файл автоматически для production-сборки в нужном месте.

Файл tsconfig.json:
{  "compilerOptions": {    "sourceMap": true,    "noImplicitAny": false,    "module": "commonjs",    "target": "es6",    "lib": [      "es2015",      "es2017",      "dom"    ],    "removeComments": true,    "allowSyntheticDefaultImports": false,    "jsx": "react",    "allowJs": true,    "baseUrl": "./",    "paths": {      "components/*": [        "src/components/*"      ]    }  }}

Поле jsx задаёт режим компиляции исходного кода. Всего есть 3 режима: preserve, react и react-native.



Файл webpack.config.json:
const path = require('path');const HtmlWebpackPlugin = require('html-webpack-plugin');module.exports = {    entry: './src/index.tsx',    resolve: {        extensions: ['.ts', '.tsx', '.js']    },    output: {        path: path.join(__dirname, '/dist'),        filename: 'bundle.min.js'    },    module: {        rules: [            {                test: /\.ts(x?)$/,                exclude: /node_modules/,                use: [                    {                        loader: "ts-loader"                    }                ]            }        ]    },    plugins: [        new HtmlWebpackPlugin({            template: './src/index.html'        })    ]};

Точка входа приложения ./src/index.tsx. С помощью resolve.extensions разрешаем обрабатывать ts/tsx/js файлы. Добавляем ts-loader и html-webpack-plugin. Сборка готова.

Разработка


В файле index.html прописываем контейнер, куда будет рендериться приложение:

<div id="root"></div>

В директории components создаем наш первый пока что пустой компонент App.tsx.
Файл index.tsx:

import * as React from 'react';import * as ReactDOM from 'react-dom';import App from "./components/App";ReactDOM.render (    <App/>,    document.getElementById("root"));

Todolist-приложение будет иметь следующую функциональность:

  • добавить задачу
  • удалить задачу
  • изменить статус задачи (выполнена / не выполнена)

Выглядеть это будет так: текстовое поле для ввода + кнопка Добавить задачу, и ниже список добавленных задач. Задачи можно удалять и менять им статус.



Для этих целей можно разделить приложение всего на два компонента создание новой задачи и список всех задач. Поэтому App.tsx на начальном этапе будет иметь следующий вид:

import * as React from 'react';import NewTask from "./NewTask";import TasksList from "./TasksList";const App = () => {    return (        <>            <NewTask />            <TasksList />        </>    )}export default App;

В текущей директории создадим и экспортируем пустые компоненты NewTask и TasksList. Так как нам необходимо обеспечить взаимосвязь между ними, нужно определить, как это будет происходить. В React существуют два подхода к взаимодействию между компонентами:

  1. Хранение текущего состояния приложения и всех его методов в родительском компоненте (в нашем случае в App.tsx) и передача дочерним компонентам через пропсы (классический способ);
  2. Хранение состояния и методов управления состоянием отдельно. В этом случае приложение нужно обернуть специальным компонентом провайдером, и передать в него необходимые для дочерних компонентов методы и свойства (использование хука useContext).

Мы будем использовать второй способ и в данном примере полностью откажемся от пропсов.

TypeScript при передаче пропсов
* Если в компонент всё же передаются пропсы, TypeScript потребует явного указания типа для компонента:

const NewTask: React.FC<MyProps> = ({taskName}) => {...

Тип React.FC, являясь дженериком, ожидает получить интерфейс (или тип) для переданных родительским компонентом параметров:

interface MyProps {    taskName: String;}


useContext


Итак, для передачи стейта воспользуемся хуком useContext. Он позволяет получать и изменять данные в любом из компонентов, обернутых провайдером.

Пример использования useContext
import * as React from 'react';import {useContext} from "react";interface Person {    name: String,    surname: String}export const PersonContext = React.createContext<Partial<Person>>({});const PersonWrapper = () => {    const person: Person = {        name: 'Spider',        surname: 'Man'    }    return (        <>            <PersonContext.Provider value={ person }>                <PersonComponent />            </PersonContext.Provider>        </>    )}const PersonComponent = () => {    const person = useContext(PersonContext);    return (        <div>            Hello, {person.name} {person.surname}!        </div>    )}export default PersonWrapper;

В примере создаём интерфейс для контекста будем передавать поля name и surname, оба типа String.

Создаём контекст методом createContext и передаём в него пока что пустой объект. Для того, чтобы TypeScript не ругался на отсутствие обязательных полей интерфейса, есть специальный тип Partial он допускает отсутствие передаваемых полей.

Далее в созданный контекст передаём данные объект person, и внутрь провайдера помещаем компонент. Теперь контекст будет доступен в любом компоненте, добавленном внутрь провайдера. Вызвать его можно как раз с помощью хука useContext.

useReducer


Также понадобится useReducer для более удобной работы с хранилищем состояния.

Подробнее о useReducer
Хук useReducer позволяет управлять стейтом посредством вызова одной единственной функции, но с разными параметрами: по соглашению, название действия передаётся в поле type, а данные в поле payload. Пример реализации:

import * as React from 'react';import {useReducer} from "react";interface PersonState {    name: String,    surname: String}interface PersonAction {    type: 'CHANGE',    payload: PersonState}const personReducer = (state: PersonState, action: PersonAction): PersonState => {    switch (action.type) {        case 'CHANGE':            return action.payload;        default: throw new Error('Unexpected action');    }}const PersonComponent = () => {    const initialState = {        name: 'Unknown',        surname: 'Guest'    }    const [person, changePerson] = useReducer<React.Reducer<PersonState, PersonAction>>(personReducer, initialState);    return (        <div onClick={() => changePerson({type: 'CHANGE', payload: {name: 'Jackie', surname: 'Chan'}})}>            Hello, {person.name} {person.surname}!        </div>    )}export default PersonComponent;

В useReducer передаём функцию-редьюсер personReducer, которая будет отрабатывать при вызове changePerson.

В переменной person изначально будет записан initialState, который по ходу вызовов changePerson будет заменяться возвращаемым редьюсером значением.

В данном примере обновления будут происходить только на действие CHANGE, но плюс редьюсера состоит в том, что логику можно легко и быстро расширить:

case 'CHANGE':   return action.payload;case 'CLEAR':   return {      name: 'Undefined',      surname: 'Undefined'   };


useContext + useReducer


Интересной заменой библиотеки Redux может быть использование контекста в связке с useReducer. В этом случае в контекст будет передаваться результат выполнения хука useReducer возвращаемый им стейт и функция для его обновления. Добавим эти хуки в приложение:

import * as React from 'react';import {useReducer} from "react";import {Action, State, ContextState} from "../types/stateType";import NewTask from "./NewTask";import TasksList from "./TasksList";// Начальные значения стейтаexport const initialState: State = {    newTask: '',    tasks: []}// <Partial> позволяет создать контекст без дефолтных значенийexport const ContextApp = React.createContext<Partial<ContextState>>({});// Создаём редьюсер, принимающий на вход текущий стейт и объект Action с полями type и payload, тип возвращаемого редьюсером значения - Stateexport const todoReducer = (state: State, action: Action):State => {    switch (action.type) {        case ActionType.ADD: {            return {...state, tasks: [...state.tasks, {                    name: action.payload,                    isDone: false                }]}        }        case ActionType.CHANGE: {            return {...state, newTask: action.payload}        }        case ActionType.REMOVE: {            return {...state, tasks:  [...state.tasks.filter(task => task !== action.payload)]}        }        case ActionType.TOGGLE: {            return {...state, tasks: [...state.tasks.map((task) => (task !== action.payload ? task : {...task, isDone: !task.isDone}))]}        }        default: throw new Error('Unexpected action');    }};const App:  React.FC = () => {// Используем созданный todoReducer, передав его аргументом в useReduser. Изначально в стейт попадёт initialState, и далее при диспатче (changeState) будет обновляться.    const [state, changeState] = useReducer<React.Reducer<State, Action>>(todoReducer, initialState);    const ContextState: ContextState = {        state,        changeState    };// Передаём в контекст результаты работы useReducer - стейт и метод его изменения    return (        <>            <ContextApp.Provider value={ContextState}>                <NewTask />                <TasksList />            </ContextApp.Provider>        </>    )}export default App;

В результате удалось сделать независимый от корневого компонента стейт, который можно получать и менять в компонентах внутри провайдера.

Typescript. Добавление типов в приложение


В файле stateType прописываем TypeScript-типы для приложения:

import {Dispatch} from "react";// Созданная задача имеет название и статус готовностиexport type Task = {    name: string;    isDone: boolean}export type Tasks = Task[];// В состоянии хранится записываемая в инпут новая задача, а также массив уже созданных задачexport type State = {    newTask: string;    tasks: Tasks}// Все возможные варианты действий со стейтомexport enum ActionType {    ADD = 'ADD',    CHANGE = 'CHANGE',    REMOVE = 'REMOVE',    TOGGLE = 'TOGGLE'}// Для действий ADD и CHANGE доступна передача только строковых значенийtype ActionStringPayload = {    type: ActionType.ADD | ActionType.CHANGE,    payload: string}// Для действий TOGGLE и REMOVE доступна передача только объекта типа Tasktype ActionObjectPayload = {    type: ActionType.TOGGLE | ActionType.REMOVE,    payload: Task}// Объединяем предыдущие две группы для использования в редьюсереexport type Action = ActionStringPayload | ActionObjectPayload;// Наш контекст состоит из стейта и функции-редьюсера, в которую будут передаваться Action. Тип Dispatch импортируется из библиотеки reactexport type ContextState = {    state: State;    changeState: Dispatch<Action>}

Использование контекста


Теперь state готов и может быть использован в компонентах. Начнём с NewTask.tsx:

import * as React from 'react';import {useContext} from "react";import {ContextApp} from "./App";import {TaskName} from "../types/taskType";import {ActionType} from "../types/stateType";const NewTask: React.FC = () => {// получаем state и dispatch-метод    const {state, changeState} = useContext(ContextApp);// отправляем два действия редьюсеру todoReducer - добавление задачи и изменение инпута. После их успешной обработки переменная state обновится. Для уточнения интерфейса передаваемого события можно воспользоваться расширенными React-интерфейсами    const addTask = (event: React.FormEvent<HTMLFormElement>, task: TaskName) => {        event.preventDefault();        changeState({type: ActionType.ADD, payload: task})        changeState({type: ActionType.CHANGE, payload: ''})    }// аналогично - отправим изменение значения в инпуте    const changeTask = (event: React.ChangeEvent<HTMLInputElement>) => {        changeState({type: ActionType.CHANGE, payload: event.target.value})    }    return (        <>            <form onSubmit={(event)=>addTask(event, state.newTask)}>                <input type='text' onChange={(event)=>changeTask(event)} value={state.newTask}/>                <button type="submit">Add a task</button>            </form>        </>    )};export default NewTask;

TasksList.tsx:

import * as React from 'react';import {Task} from "../types/taskType";import {ActionType} from "../types/stateType";import {useContext} from "react";import {ContextApp} from "./App";const TasksList: React.FC = () => {// Получаем состояние и диспатч (названный changeState)    const {state, changeState} = useContext(ContextApp);    const removeTask = (taskForRemoving: Task) => {        changeState({type: ActionType.REMOVE, payload: taskForRemoving})    }    const toggleReadiness = (taskForChange: Task) => {        changeState({type: ActionType.TOGGLE, payload: taskForChange})    }    return (        <>            <ul>                {state.tasks.map((task,i)=>(                    <li key={i} className={task.isDone ? 'ready' : null}>                        <label>                            <input type="checkbox" onChange={()=>toggleReadiness(task)} checked={task.isDone}/>                        </label>                        <div className="task-name">                            {task.name}                        </div>                        <button className='remove-button' onClick={()=>removeTask(task)}>                            X                        </button>                    </li>                ))}            </ul>        </>    )};export default TasksList;

Приложение готово! Осталось протестировать его.

Тестирование


Для тестирования будут использоваться Jest + Enzyme, а также @testing-library/react.
Необходимо установить dev-зависимости:

"@testing-library/react": "^10.4.3","@testing-library/react-hooks": "^3.3.0","@types/enzyme": "^3.10.5","@types/jest": "^24.9.1","enzyme": "^3.11.0","enzyme-adapter-react-16": "^1.15.2","enzyme-to-json": "^3.3.4","jest": "^26.1.0","ts-jest": "^26.1.1",

В package.json добавляем настройки для jest:

 "jest": {    "preset": "ts-jest",    "setupFiles": [      "./src/__tests__/setup.ts"    ],    "snapshotSerializers": [      "enzyme-to-json/serializer"    ],    "testRegex": "/__tests__/.*\\.test.(ts|tsx)$",    "moduleFileExtensions": [      "ts",      "tsx",      "js",      "jsx",      "json",      "node"    ]  },

и в блоке scripts добавляем скрипт запуска тестов:

"test": "jest"

Создаём в директории src новый каталог __tests__ и в нем файл setup.ts с таким содержимым:

import {configure} from 'enzyme';import * as ReactSixteenAdapter from 'enzyme-adapter-react-16';const adapter = ReactSixteenAdapter as any;configure({ adapter: new adapter() });

Создадим файл todoReducer.test.ts, в котором протестируем редьюсер:

import {todoReducer} from "../reducers/todoReducer";import {ActionType, Action, State} from "../types/stateType";import {Task} from "../types/taskType";describe('todoReducer',()=>{    it('returns new state for "ADD" type', () => {// Создаём стейт с пустым массивом задач        const initialState: State = {newTask: '', tasks: []};// Создаём действие 'ADD' и передаём в него текст 'new task'        const updateAction: Action = {type: ActionType.ADD, payload: 'new task'};// Вызываем редьюсер с переданными стейтом и экшеном        const updatedState = todoReducer(initialState, updateAction);// Ожидаем получить в стейте добавленную задачу        expect(updatedState).toEqual({newTask: '', tasks: [{name: 'new task', isDone: false}]});    });    it('returns new state for "REMOVE" type', () => {        const task: Task = {name: 'new task', isDone: false}        const initialState: State = {newTask: '', tasks: [task]};        const updateAction: Action = {type: ActionType.REMOVE, payload: task};        const updatedState = todoReducer(initialState, updateAction);        expect(updatedState).toEqual({newTask: '', tasks: []});    });    it('returns new state for "TOGGLE" type', () => {        const task: Task = {name: 'new task', isDone: false}        const initialState: State = {newTask: '', tasks: [task]};        const updateAction: Action = {type: ActionType.TOGGLE, payload: task};        const updatedState = todoReducer(initialState, updateAction);        expect(updatedState).toEqual({newTask: '', tasks: [{name: 'new task', isDone: true}]});    });    it('returns new state for "CHANGE" type', () => {        const initialState: State = {newTask: '', tasks: []};        const updateAction: Action = {type: ActionType.CHANGE, payload: 'new task'};        const updatedState = todoReducer(initialState, updateAction);        expect(updatedState).toEqual({newTask: 'new task', tasks: []});    });})

Для тестирования редьюсера достаточно передать ему текущий стейт и действие, и затем ловить результат его выполнения.

Тестирование компонента App.tsx, в отличие от редьюсера, требует использования дополнительных методов из разных библиотек. Тестовый файл App.test.tsx:

import * as React from 'react';import {shallow} from 'enzyme';import {fireEvent, render, cleanup} from "@testing-library/react";import App from "../components/App";describe('<App />', () => {// jest-функция afterEach с переданным коллбеком cleanup вызывается после каждого теста и очищает среду тестирования    afterEach(cleanup);    it('hasn`t got changes', () => {//  метод shallow библиотеки enzyme позволяет производить юнит-тестирование, без отрисовки дочерних компонентов.         const component = shallow(<App />);// При первом запуске теста будет создан снимок компонента. При последующих тестированиях будет проверяться идентичность снимка с текущим содержимым компонента. Для обновления snapshots необходимо запустить тест с флагом -u: jest -u        expect(component).toMatchSnapshot();    });// Так как в компоненте будут происходить асинхронные действия (вызываться события на DOM-элементах), оборачиваем тест в async    it('should render right input value',  async () => {// render() функция доступна в библиотеке @testing-library/react" и отличается от shallow() тем, что строит настоящее DOM-дерево для тестируемого компонента. Переменная container   это элемент div, в который будет выведен компонент.        const { container } = render(<App/>);        expect(container.querySelector('input').getAttribute('value')).toEqual('');// вызываем событие изменения инпута и передаём туда значение 'test'        fireEvent.change(container.querySelector('input'), {            target: {                value: 'test'            },        })// ожидаем получить в инпуте значение 'test'        expect(container.querySelector('input').getAttribute('value')).toEqual('test');// вызываем событие клика на кнопку. При этом событии поле инпута должно очищаться        fireEvent.click(container.querySelector('button'))// ожидаем получить в инпуте пустое значение атрибута value        expect(container.querySelector('input').getAttribute('value')).toEqual('');    });})

В TasksList компоненте проверим, правильно ли отображается передаваемый стейт. Файл TasksList.test.tsx:

import * as React from 'react';import {ContextApp, initialState} from "../components/App";import {shallow} from "enzyme";import {cleanup, render} from "@testing-library/react";import TasksList from "../components/TasksList";import {State} from "../types/stateType";describe('<TasksList />',() => {    afterEach(cleanup);// Создаём тестовый стейт    const testState: State = {        newTask: '',        tasks: [{name: 'test', isDone: false}, {name: 'test2', isDone: false}]    }// Передаём в ContextApp созданный тестовый стейт    const Wrapper = () => {        return (            <ContextApp.Provider value={{state: testState}}>                <TasksList/>            </ContextApp.Provider>            )    }    it('should render right tasks length', async () => {        const {container} = render(<Wrapper/>);// Проверяем длину отображаемого списка        expect(container.querySelectorAll('li')).toHaveLength(testState.tasks.length);    });})

Аналогичную проверку поля newTask можно сделать для компонента NewTask, проверяя value у элемента input.

Проект можно скачать с GitGub-репозитория.

На этом всё, спасибо за внимание.

Ресурсы


React JS. Хуки
Working with React Hooks and TypeScript
Подробнее..

Эпическая сага про маленький custom hook для React (генераторы, sagas, rxjs)

11.12.2020 02:23:21 | Автор: admin

Прелюдия

Стояла задача реализовать прелоадер с обратным отсчетом на реакте. Т.к. гуглить не умею и очень люблю лепить велосипеды (хороший двухместный получился из двух велосипедов "Украина"), то я был обречен глубоко копать на полях асинхреньщины. Забегу наперёд и скажу, что реализовал этот прелоадер с помощью генераторов, redux-saga, rxjs. Очень интересный опыт и хотелось бы поделиться, учитывая, что в процессе разбирательств, кроме статей, описывающих очевидные вещи, и обрывков сугубо специфической информации на stackoverflow, не находил.

Итак: часть первая - создание кастомного хука.

Структура прелоадера

В качестве шкалы индикатора загрузки, я выбрал количество картинок на сайте (можно выбирать и другие ресурсы), т.к. в моём случае это были самые тяжёлые ресурсы страницы.

Т.к. сделать это нужно было в реакте, то конечно же оформил это в виде кастомного хука и назвал его usePreloader.

Код самого прелоадера
import React from "react";export default function Preloader() {  return (    <div className="preloader__wrapper">      <div className="preloader">        <svg          width="300"          height="300"          viewBox="0 0 300 300"          fill="none"          xmlns="http://personeltest.ru/away/www.w3.org/2000/svg"        >          <path            d="M300 150C300 232.843 232.843 300 150 300C67.1573 300 0 232.843 0 150C0 67.1573 67.1573 0 150 0C232.843 0 300 67.1573 300 150ZM20.7679 150C20.7679 221.373 78.6271 279.232 150 279.232C221.373 279.232 279.232 221.373 279.232 150C279.232 78.6271 221.373 20.7679 150 20.7679C78.6271 20.7679 20.7679 78.6271 20.7679 150Z"            fill="#F3F3F3"          />          <path            d="M289.616 150C295.351 150 300.037 154.655 299.641 160.376C297.837 186.392 289.275 211.553 274.72 233.336C258.238 258.003 234.811 277.229 207.403 288.582C179.994 299.935 149.834 302.906 120.736 297.118C91.6393 291.33 64.9119 277.044 43.934 256.066C22.9561 235.088 8.66999 208.361 2.88221 179.264C-2.90558 150.166 0.0649254 120.006 11.4181 92.5975C22.7712 65.1886 41.9971 41.7618 66.6645 25.2796C88.4468 10.725 113.608 2.16293 139.624 0.359232C145.345 -0.0374269 150 4.64906 150 10.384C150 16.1189 145.343 20.7246 139.627 21.1849C117.723 22.9486 96.5686 30.2756 78.2025 42.5475C56.9504 56.7477 40.3864 76.931 30.6052 100.545C20.8239 124.159 18.2647 150.143 23.2511 175.212C28.2375 200.28 40.5457 223.307 58.6191 241.381C76.6926 259.454 99.7195 271.762 124.788 276.749C149.857 281.735 175.841 279.176 199.455 269.395C223.069 259.614 243.252 243.05 257.453 221.797C269.724 203.431 277.051 182.277 278.815 160.373C279.275 154.657 283.881 150 289.616 150Z"            fill="#6CAE30"          />        </svg>      </div>      <div className="preloader__counter">0%</div>    </div>  );}
Стили прелоадера
.App {  font-family: sans-serif;  text-align: center;}.preloader__wrapper {  position: fixed;  display: flex;  flex-direction: column;  align-items: center;  justify-content: center;  top: 0;  left: 0;  width: 100%;  height: 100%;  background-color: #ffffff;  z-index: 2000;}.preloader {  position: relative;  width: 18.75rem;  height: 18.75rem;}.preloader svg {  width: 18.75rem;  height: 18.75rem;  -webkit-animation: spin 4s infinite linear;  animation: spin 4s infinite linear;}.preloader__counter {  margin-top: 1.625rem;  padding-left: 1rem;  font-family: sans-serif;  font-size: 3.125rem;  line-height: 1;  color: #6cae30;}@media (max-width: 900px) {  .preloader {    width: 6.25rem;    height: 6.25rem;  }  .preloader svg {    width: 6.25rem;    height: 6.25rem;  }  .preloader__counter {    margin-top: 0.75rem;    padding-left: 0.5rem;    font-size: 2.125rem;  }}@-moz-keyframes spin {  from {    -moz-transform: rotate(0deg);  }  to {    -moz-transform: rotate(360deg);  }}@-webkit-keyframes spin {  from {    -webkit-transform: rotate(0deg);  }  to {    -webkit-transform: rotate(360deg);  }}@keyframes spin {  from {    transform: rotate(0deg);  }  to {    transform: rotate(360deg);  }}

Реализация хука

Логикой хука является поиск всех тэгов <img> на странице и подключение обработчиков на события load и error. Делается это с помощью следующего кода

const updateCounter = () => {  dispatch({    type: ACTIONS.SET_COUNTER,    data: state.counter + state.counterStep  });};const checkImageLoading = (url) => {  const imageChecker = new Image();  imageChecker.addEventListener("load", updateCounter);  imageChecker.addEventListener("error", updateCounter);  imageChecker.src = url;};

У хука будет состояние (state), в котором будет хранится шаг индикатора и текущее состояние индикатора. В данной (первой) части прелоадер реализован только с помощью хуков react, но в нескольких вариантах. Подключать redux для хранения состояния не будем, т.к. это кастомный хук и в будущем хотелось бы его переиспользовать.

Исходный код state.js
export const SET_COUNTER = "SET_COUNTER";export const SET_COUNTER_STEP = "SET_COUNTER_STEP";export const initialState = {  counter: 0,  counterStep: 0,};export const reducer = (state, action) => {  switch (action.type) {    case SET_COUNTER:      return { ...state, counter: action.data };    case SET_COUNTER_STEP:      return { ...state, counterStep: action.data };    default:      throw new Error("This action is not applicable to this component.");  }};export const ACTIONS = {  SET_COUNTER,  SET_COUNTER_STEP,};

Вариант 1 (не рабочий, для наглядности). useReducer

Этот вариант показан для наглядности, чтобы было понятно, в каких случаях без useRef не обойтись. Метод checkImageLoading выполняется в цикле при монтировании компонента и поэтому ссылка на state у всех обработчиков будет указывать на начальное состояние.

Исходный код usePreloader.js
import { useReducer, useEffect } from "react";import { reducer, initialState, ACTIONS } from "./state";const PRELOADER_SELECTOR = ".preloader__wrapper";const PRELOADER_COUNTER_SELECTOR = ".preloader__counter";const usePreloader = () => {  const [state, dispatch] = useReducer(reducer, initialState);  const preloaderEl = document.querySelector(PRELOADER_SELECTOR);  const counterEl = document.querySelector(PRELOADER_COUNTER_SELECTOR);  const updateCounter = () => {    dispatch({      type: ACTIONS.SET_COUNTER,      data: state.counter + state.counterStep    });  };  const checkImageLoading = (url) => {    const imageChecker = new Image();    imageChecker.addEventListener("load", updateCounter);    imageChecker.addEventListener("error", updateCounter);    imageChecker.src = url;  };  useEffect(() => {    const imgArray = document.querySelectorAll("img");    if (imgArray.length > 0) {      dispatch({        type: ACTIONS.SET_COUNTER_STEP,        data: Math.floor(100 / imgArray.length) + 1      });      imgArray.forEach((img) => {        checkImageLoading(img.src);      });    }  }, []);  useEffect(() => {    if (counterEl) {      state.counter < 100        ? (counterEl.innerHTML = `${state.counter}%`)        : hidePreloader(preloaderEl);    }  }, [state]);  return;};const hidePreloader = (preloaderEl) => {  preloaderEl.remove();};export default usePreloader;

Вариант 2. useReducer + useRef

Объект, создаваемый с помощью useRef существует на протяжении всего существования компонента. В него я буду записывать ссылку на обновленный state и таким образом из колбэков будет доступно актуальное состояние хука.

Для этого нужно создать этот объект:

//сохраняю ссылку на stateconst stateRef = useRef(state);

В колбэке ссылаться на state через свойство current переменной stateRef:

const updateCounter = () => {  dispatch({    type: ACTIONS.SET_COUNTER,    data: stateRef.current.counter + stateRef.current.counterStep //данные беру не из state, а из stateRef  });};

Также ссылаюсь на state при обновлении значения индикатора в разметке

stateRef.current.counter < 100? (counterEl.innerHTML = `${stateRef.current.counter}%`) //данные беру не из state, а из stateRef: hidePreloader(preloaderEl);

При каждом обновлении state, нужно обновить ссылку:

useEffect(() => {  stateRef.current = state; //при каждом обновлении state, записываю ссылку на обновленный state  ...}, [state]);
Исходный код useReducer + useRef
import { useReducer, useEffect, useRef } from "react";import { reducer, initialState, ACTIONS } from "./state";const PRELOADER_SELECTOR = ".preloader__wrapper";const PRELOADER_COUNTER_SELECTOR = ".preloader__counter";const usePreloader = () => {  const [state, dispatch] = useReducer(reducer, initialState);  //сохраняю ссылку на state  const stateRef = useRef(state);  const preloaderEl = document.querySelector(PRELOADER_SELECTOR);  const counterEl = document.querySelector(PRELOADER_COUNTER_SELECTOR);  const updateCounter = () => {    dispatch({      type: ACTIONS.SET_COUNTER,      data: stateRef.current.counter + stateRef.current.counterStep //данные беру не из state, а из stateRef    });  };  const checkImageLoading = (url) => {    const imageChecker = new Image();    imageChecker.addEventListener("load", updateCounter);    imageChecker.addEventListener("error", updateCounter);    imageChecker.src = url;  };  useEffect(() => {    const imgArray = document.querySelectorAll("img");    if (imgArray.length > 0) {      dispatch({        type: ACTIONS.SET_COUNTER_STEP,        data: Math.floor(100 / imgArray.length) + 1      });      imgArray.forEach((img) => {        checkImageLoading(img.src);      });    }  }, []);  useEffect(() => {    stateRef.current = state; //при каждом обновлении state, записываю ссылку на обновленный state    if (counterEl) {      stateRef.current.counter < 100        ? (counterEl.innerHTML = `${stateRef.current.counter}%`) //данные беру не из state, а из stateRef        : hidePreloader(preloaderEl);    }  }, [state]);  return;};const hidePreloader = (preloaderEl) => {  preloaderEl.remove();};export default usePreloader;

Но тут появляются ньюансы. Если принудительно ограничить скорость интернета (закладка network в devtools), то всё работает нормально. А если интернет скоростной, то прелоадер зависает на определенном значении. Т.е. хук

useEffect(() => {  ...}, [state]);

не отрабатывает каждый раз при изменении state. А соответственно ссылка stateRef.current не обновляется и остаётся неактуальной. Это происходит потому что реакт оптимизирует отрисовку.

Чтобы решить эту проблему, вместо useEffect нужно использовать useLayoutEffect:

  useLayoutEffect(() => {    stateRef.current = state;    if (counterEl) {      stateRef.current.counter < 100        ? (counterEl.innerHTML = `${stateRef.current.counter}%`)        : hidePreloader(preloaderEl);    }  }, [state]);

После этого прелоадер стал работать стабильно.

useEffect - выполняется асинхронно. Т.е. пока он выполнится - state может поменяться несколько раз.

useLayoutEffect - выполняется синхронно. Т.е. при каждом изменении состояния компонента этот хук будет выполнен и отрисовка будет произведена.

Практическое отличие я бы сформулировал так: хотите, чтобы компонент перересовывался каждый раз при изменении состояния компонента, используйте useLayoutEffect; если для Вас не важно отображение пошагового состояния, а можно отрендерить последнюю версию состояния - useEffect.

Вариант 3. useState + useRef

Я использовал useReducer, т.к. состояние содержит два значения: шаг счетчика и текущее состояние. Но можно реализовать и с помощью useState. Например состояние couterStep будет реализовано так:

  const [counterStep, setCounterStep] = useState(0);  const counterStepStateRef = useRef(counterStep);  const setCounterStepState = (data) => {    counterStepStateRef.current = data;    setCounterStep(data);  };
Исходный код useState + useRef
import { useEffect, useLayoutEffect, useRef, useState } from "react";const PRELOADER_SELECTOR = ".preloader__wrapper";const PRELOADER_COUNTER_SELECTOR = ".preloader__counter";const usePreloader = () => {  const [counter, setCounter] = useState(0);  const counterStateRef = useRef(counter);  const setCounterState = (data) => {    counterStateRef.current = data;    setCounter(data);  };  const [counterStep, setCounterStep] = useState(0);  const counterStepStateRef = useRef(counterStep);  const setCounterStepState = (data) => {    counterStepStateRef.current = data;    setCounterStep(data);  };  const preloaderEl = document.querySelector(PRELOADER_SELECTOR);  const counterEl = document.querySelector(PRELOADER_COUNTER_SELECTOR);  const updateCounter = () => {    setCounterState(counterStateRef.current + counterStepStateRef.current);  };  const checkImageLoading = (url) => {    const imageChecker = new Image();    imageChecker.addEventListener("load", updateCounter);    imageChecker.addEventListener("error", updateCounter);    imageChecker.src = url;  };  useEffect(() => {    const imgArray = document.querySelectorAll("img");    if (imgArray.length > 0) {      setCounterStepState(Math.floor(100 / imgArray.length) + 1);      imgArray.forEach((img) => {        checkImageLoading(img.src);      });    }  }, []);  useLayoutEffect(() => {    if (counterEl) {      counterStateRef.current < 100        ? (counterEl.innerHTML = `${counterStateRef.current}%`)        : hidePreloader(preloaderEl);    }  }, [counter, counterStep]);  return;};const hidePreloader = (preloaderEl) => {  preloaderEl.remove();};export default usePreloader;

Основное приложение

Для использования в приложении этого хука достаточно вызывать его

export default function App() {  usePreloader();  return (    <div className="App">      <Preloader />      ...    </div>  );}

Итого:

В этой части статьи показано:

  • как создать кастомный хук

  • как управлять состоянием хука с помощью хуков useReducer и useState

  • как использовать useRef для хранения ссылки на состояние

  • различие в поведении компонента при использовании хуков useEffect и useLayoutEffect

Ссылка на песочницу

Ссылка на репозиторий

На этом можно было бы и закончить, т.к. хук работает. Но этот прелоадер неожиданно оказался отличным примером для работы с потоком событий и их асинхронной обработкой. Поэтому самое интересное начинается в следующей части.

Начнём, а вернее продолжим, с генераторами...

Подробнее..

Перевод Почему Context это не инструмент управления состоянием

26.01.2021 16:05:52 | Автор: admin


TL;DR


Context и Redux это одно и тоже?

Нет. Это разные инструменты, делающие разные вещи и используемые в разных целях.

Является ли контекст инструментом управления состоянием?

Нет. Контекст это форма внедрения зависимостей (dependency injection). Это транспортный механизм, который ничем не управляет. Любое управление состоянием осуществляется вручную, как правило, с помощью хуков useState()/useReducer().

Являются ли Context и useReducer() заменой Redux?

Нет. Они в чем-то похожи и частично пересекаются, но сильно отличаются в плане возможностей.

Когда следует использовать контекст?

Когда вы хотите сделать некоторые данные доступными для нескольких компонентов, но не хотите передавать эти данные в виде пропов на каждом уровне дерева компонентов.

Когда следует использовать Context и useReducer()?

Когда вам требуется управление состоянием умеренно сложного компонента в определенной части приложения.

Когда следует использовать Redux?

Redux наиболее полезен в следующих случаях:

  • Большое количество компонентов с состояниями, в которых используются одни и те же данные
  • Состояние приложение часто обновляется
  • Сложная логика обновления состояния
  • Приложение имеет среднюю или большую кодовую базу и над ним работает много людей
  • Вы хотите знать когда, почему и как обновляется состояние приложения и иметь возможность визуализировать эти изменения
  • Вам требуются более мощные возможности по управлению побочными эффектами, стабильностью (постоянством) и сериализацией данных


Понимание Context и Redux


Для правильного использования инструмента критически важно понимать:

  • Для чего он предназначен
  • Какие задачи он решает
  • Когда и зачем он был создан

Также важно понимать, какие задачи вы пытаетесь решить в вашем приложениии, и использовать те интструменты, которые решают их наилучшим образом, а не потому, что кто-то сказал вам их использовать, и не потому, что они являются популярными, а потому, что они лучше всего подходят вам в определенной ситуации.

Неразбериха вокруг Context и Redux связана, в первую очередь, с непониманием того, для чего данные инструменты предназначены, и какие задачи они решают. Поэтому, прежде чем говорить о том, когда их следует использовать, необходимо определить, что они из себя представляют и какие проблемы решают.

Что такое контекст?


Начнем с определения контекста из официальной документации:

Контекст позволяет передавать данные через дерево компонентов без необходимости передавать пропы на промежуточных уровнях.

В типичном React-приложении данные передаются сверху вниз (от предка к потомку) с помощью пропов. Однако, этот способ может быть чересчур громоздким для некоторых типов пропов (например, выбранный язык, тема интерфейса), которые необходимо передавать во многие компоненты в приложении. Контекст предоставляет способ распределять такие данные между компонентами без необходимости явно передавать пропы на каждом уровне дерева компонентов.

Обратите внимание, в данном определении ни слова не говорится об управлении, только о передаче и распределении.

Текущее API контекста (React.createContext()) впервые было представлено в React 16.3 в качестве замены устаревшего API, доступного в ранних версиях React, но имеющего несколько недостатков дизайна. Одной из главных проблемой являлось то, что обновления значений, переданных через контекст, могли быть заблокированы, если компонент пропускал рендеринг через shouldComponentUpdate(). Поскольку многие компоненты прибегали к shouldComponentUpdate() в целях оптимизации, передача данных через контекст становилась бесполезной. createContext() был спроектирован для решения этой проблемы, поэтому любое обновление значения отразится на дочерних компонентах, даже если промежуточный компонент пропускает рендеринг.

Использование контекста

Использование контекста в приложении предполагает следующее:

  • Вызываем const MyContext = React.createContext() для создания экземпляра объекта контекста
  • В родительском компоненте рендерим &ltMyContext.Provider value={someValue}>. Это помещает некоторые данные в контекст. Эти данные могут быть чем угодно: строкой, числом, объектом, массивом, экземпляром класса, обработчиком событий и т.д.
  • Получаем значение контекста в любом компоненте внутри провайдера, вызывая const theContextValue = useContext(MyContext)

При обновлении родительского компонента и передачи новой ссылки в качестве значения провайдера, любой компонент, потребляющий контекст, будет принудительно обновлен.

Обычно, значением контекста является состояние компонента:

import { createContext } from 'react'export const MyContext = createContext()export function ParentComponent({ children }) {  const [counter, setCounter] = useState(0)  return (    <MyContext.Provider value={[counter, setCounter]}>      {children}    </MyContext.Provider>  )}

После этого дочерний компонент может вызвать хук useContext() и прочитать значение контекста:

import { useContext } from 'react'import { MyContext } from './MyContext'export function NestedChildComponent() {  const [counter, setCounter] = useContext(MyContext)  // ...}

Цель и случаи использования контекста

Мы видим, что контекст, в действительности, ничем не управляет. Вместо этого, он представляет собой своего рода тоннель (pipe). Вы помещаете данные в начало (наверх) тоннеля с помощью <MyContext.Provider>, затем эти данные опускаются вниз до тех пор, пока компонет не запросит их с помощью useContext(MyContext).

Таким образом, основная цель контекста состоит в предотвращении бурения пропов (prop-drilling). Вместо передачи данных в виде пропов на каждом уровне дерева компонентов, любой компонент, вложенный в <MyContext.Provider>, может получить к ним доступ посредством useContext(MyContext). Это избавляет от необходимости писать код, реализующий логику передачи пропов.

Концептуально, это является формой внедрения зависимостей. Мы знаем, что потомок нуждается в данных определенного типа, но он не пытается создавать или устанавливать эти данные самостоятельно. Вместо этого, он полагается на то, что некоторый предок передаст эти данные во время выполнения (runtime).

Что такое Redux?


Вот о чем гласит определение из Основ Redux:

Redux это паттерн проектирования и библиотека для управления и обновления состояния приложения, использующая события, именуемые операциями (действиями, actions). Redux выступает в роли централизованного хранилища состояния приложения, следюущего правилам, позволяющим обеспечить предсказуемое обновление состояние.

Redux позволяет управлять глобальным состоянием состоянием, которое трубется нескольким частям приложения.

Паттерны и инструменты, предоставляемые Redux, позволяют легче определять где, когда, почему и как было обновлено состояние, и как отреагировало приложение на это изменение.

Обратите внимание, что данное описание указывает на:

  • Управление состоянием
  • Цель Redux определение того, почему и как произошло изменение состояния

Изначально Redux являлся реализацией архитектуры Flux, паттерна проектирования, разработанного Facebook в 2014 году, через год после появления React. После появления Flux, сообщество разработало множество библиотек, по-разному реализующих данную концепцию. Redux появился в 2015 году и быстро стал победителем в этом соревновании благодаря продуманному дизайну, решению наиболее распространенных проблем и отличной совместимости с React.

Архитектурно, Redux подчеркнуто использует принципы функционального программирования, что позволяет писать код в форме предсказуемых функций-редукторов (reducers), и обособлять идею какое событие произошло от логики, определяющей как обновляется состояние при возгникновении данного события. В Redux также используется промежуточное программное обеспечение (middleware) как способ расширения возможностей хранилища, включая обработку побочных эффектов (side effects).

Redux также предоставляет инструменты разработчика, позволяющие изучать историю операций и изменение состояния в течение времени.

Redux и React

Сам по себе Redux не зависит от UI вы можете использовать его с любым слоем представления (view layer) (React, Vue, Angular, ванильный JS и т.д.) либо без UI вообще.

Однако, чаще всего, Redux используется совместно с React. Библиотека React Redux это официальный связывающий слой UI, позволяющий React-компонентам взаимодействовать с хранилищем Redux, получая значения из состояния Redux и инициализируя выполнение операций. React-Redux использует контекст в своих внутренних механизмах. Тем не менее, следует отметить, что React-Redux передает через контекст экземпляр хранилища Redux, а не текущее значение состояния! Это пример использования контекста для внедрения зависимостей. Мы знаем, что наши подключенные к Redux компоненты нуждаются во взаимодействии с хранилищем Redux, но мы не знаем или нам неважно, что это за хранилище, когда мы определяем компонент. Настоящее хранилище Redux внедряется в дерево во время выполнения с помощью компонента <Provider>, предоставляемого React-Redux.

Следовательно, React-Redux также может быть использован для предотвращения бурения (по причине внутреннего использования контекста). Вместо явной передачи нового значения через <MyContext.Provider>, мы можем поместить эти данные в хранилище Redux и затем получить их в нужном компоненте.

Цель и случаи использования (React-)Redux

Основное назначение Redux согласно официальной документации:

Паттерны и инструменты, предоставляемые Redux, облегчают понимание того, когда, где, почему и как произошло изменение состояния, а также того, как на это отреагировало приложение.

Существует еще несколько причин использования Redux. Одной из таких причин является предотвращение бурения.

Другие случаи использования:

  • Полное разделение логики управления состоянием и слоя UI
  • Распределение логики управления состоянием между разными слоями UI (например, в процессе перевода приложения с AngularJS на React)
  • Использование возможностей Redux middleware для добавления дополнительной логики при инициализации операций
  • Возможность сохранения частей состояния Redux
  • Возможность получения отчетов об ошибках, которые могут быть воспроизведены другими разработчиками
  • Возможность быстрой отладки логики и UI во время разработки

Дэн Абрамов перечислил эти случаи в статье 2016 года Почему вам может быть не нужен Redux.

Почему контекст не является инструментом управления состоянием?


Состояние это любые данные, описывающие поведение приложения. Мы можем разделить состояние на такие категории, как состояние сервера, состояние коммуникаций и локальное состояние, если хотим, но ключевым аспектом является хранение, чтение, обновление и использование данных.

David Khourshid, автор библиотеки XState и специалист по управлению состоянием, в одном из своих твитов отметил, что:

Управление состоянием это изменение состояния в течение времени.

Таким образом, мы можем сказать, что управление состоянием означает следующее:

  • Сохранение начального значения
  • Получение текущего значения
  • Обновление значения

Также, как правило, существует способ получения уведомлений об изменении текущего значения состояния.

React-хуки useState() и useReducer() являются отличными примерами управления состоянием. С помощью этих хуков мы можем:

  • Сохранять начальное значение путем вызова хука
  • Получать текущее значение также посредством вызова хука
  • Обновлять значение, вызывая функцию setState() или dispatch(), соответственно
  • Узнавать об обновлении состояния благодаря повторному рендерингу компонента

Redux и MobX также позволяют управлять состоянием:

  • Redux сохраняет начальное значение путем вызова корневого редуктора (root reducer), позволяет читать текущее значение с помощью store.getState(), обновлять значение с помощью store.dispatch(action) и получать уведомления об обновлении состояния через store.subscribe(listener)
  • MobX сохраняет начальное значение путем присвоения значения полю класса хранилища, позволяет читать текущее значение и обновлять его через поля хранилища и получать учведомления об обновлении состояния с помощью методов autorun() и computed()

К инструментам управления состоянием можно причислить даже инструменты для работы с кэшем сервера, такие как React-Query, SWR, Apollo и Urql они сохраняют начальное значение на основе полученных (fetched) данных, возвращают текущее значение с помощью хуков, позволяют обновлять значения посредством серверных мутаций и уведомляют об изменениях с помощью повторного рендеринга компонента.

React Context не соответствует названным критериям. Поэтому он не является инструментом управления состоянием

Как было отмечено ранее, контекст сам по себе ничего не хранит. За передачу значения, которое, обычно, зависит от состояния компонента, в контекст отвечает родительский компонент, который рендерит <MyContext.Provider>. Настоящее управление состоянием происходит при использовании хуков useState()/useReducer().

David Khourshid также отмечает:

Контекст это то, как существующее состояние распределяется между компонентами. Контекст ничего не делает с состоянием.

И в более позднем твите:

Полагаю, контекст это как скрытые пропы, абстрагирующие состояние.

Все, что делает контекст, это позволяет избежать бурения.

Сравнение Context и Redux


Сравним возможности контекста и React+Redux:

  • Context
    • Ничего не хранит и ничем не управляет
    • Работает только в компонентах React
    • Передает ниже простое (единственное) значение, которое может быть чем угодно (примитивом, объектом, классом и т.д.)
    • Позволяет читать это простое значение
    • Может использоваться для предотвращения бурения
    • Показывает текущее значение для компонентов Provider и Consumer в инструментах разработчика, но не показывает историю изменений этого значения
    • Обновляет потребляющие компоненты при изменении значения, но не позволяет пропустить обновление
    • Не предоставляет механизма для обработки побочных эффектов отвечает только за рендеринг

  • React+Redux
    • Хранит и управляет простым значением (обычно, этим значением является объект)
    • Работает с любым UI, а также за пределами React-компонентов
    • Позволяет читать это простое значение
    • Может использоваться для предотвращения бурения
    • Может обновлять значение путем инициализации операций и запуска редукторов
    • Инструменты разработчика показывают историю инициализации операций и изменения состояния
    • Предоставляет возможность использования middleware для обработки побочных эффектов
    • Позволяет компонентам подписываться на обновления хранилища, извлекать определенные части состояния хранилища и контролировать повторный рендеринг компонентов


Очевидно, что это совершенно разные инструменты с разными возможностями. Единственной точкой пересечения между ними является предотвращение бурения.

Context и useReducer()


Одной из проблем в дискуссии Context против Redux является то, что люди, зачастую, на самом деле имеют ввиду следующее: Я использую useReducer() для управления состоянием и контекст для передачи значения. Но, вместо этого, они просто говорят: Я использую контекст. В этом, на мой взгляд, кроется основная причина неразберихи, способствующая поддержанию мифа о том, что контекст управляет состоянием.

Рассмотрим комбинацию Context + useReducer(). Да, такая комбинация выглядит очень похоже на Redux + React-Redux. Обе эти комбинации имеют:

  • Сохраненное значение
  • Функцию-редуктор
  • Возможность инициализации операций
  • Возможность передачи значения и его чтения во вложенных компонентах

Тем не менее, между ними по-прежнему существуют некоторые важные отличия, проявляющиеся в их возможностях и поведении. Я отметил эти отличия в статьях Поведение React, Redux и Context и "(Почти) полное руководство по рендерингу в React". Суммируя, можно отметить следующее:

  • Context + useReducer() основан на передаче текущего значения через контекст. React-Redux передает через контекст текущий экземпляр хранилища Redux
  • Это означает, что когда useReducer() производит новое значение, все компоненты, подписанные на контекст, принудительно перерисовываются, даже если они используют только часть данных. Это может привести к проблемам с производительностью в зависимости от размера значения состояния, количества подписанных компонентов и частоты повторного рендеринга. При использовании React-Redux компоненты могут подписываться на определенную часть значения хранилища и перерисовываться только при изменении этой части

Существуют и другие важные отличия:

  • Контекст + useReducer() являются встроенными возможностями React и не могут использоваться за его пределами. Хранилище Redux не зависит от UI, поэтому может использоваться отдельно от React
  • React DevTools показывают текущее значение контекста, но не историю его изменений. Redux DevTools показывают все инициализированные операции, их содержимое (тип и полезную нагрузку, type and payload), состояние после каждой операции и разницу между состояниями
  • useReducer() не имеет middleware. Некоторые побочные эффекты можно обработать с помощью хука useEffect() в сочетании с useReducer(), я даже встречал отдельные попытки оборачивания useReducer() в нечто похожее на middleware, однако всему этому далеко до функционала и возможностей Redux middleware

Вот, что сказал Sebastian Markbage (архитектор команды ядра React) об использовании контекста:

Мое личное мнение состоит в то, что новый контекст готов к использованию для маловероятных обновлений с низкой частотой (таких как локализация или тема). Он также может использоваться во всех тех случаях, в которых использовался старый контекст, т.е. для статических значений с последующим распространением обновления по подпискам. Он не готов к использованию в качестве замены Flux-подобных распространителей состояния.

В сети существует много статей, рекомендующих настройку нескольких отдельных контекстов для разных частей состояния, что позволяет избежать ненужных повторных рендерингов и решить проблемы, связанные с областями видимости. Некоторые из постов также предлагают добавлять собственные компоненты по выбору контекста, что требует использования сочетания React.memo(), useMemo() и аккуратного разделения кода на два контекста для каждой части приложения (одна для данных, другая для функций обновления). Безусловно, код можно писать и так, но в этом случае вы заново изобретаете React-Redux.

Таким образом, несмотря на то, что Context + useReducer() это легкая альтернатива Redux + React-Redux в первом приближении эти комбинации не идентичны, контекст + useReducer() не может полностью заменить Redux!

Выбор правильного инструмента


Для выбора правильного инструмента очень важно понимать, какие задачи решает инструмент, а также какие задачи стоят перед вами.

Обзор случаев использования


  • Context
    • Передача данных вложенным компонентам без бурения

  • useReducer()
    • Управление состоянием сложного компонента с помощью функции-редуктора

  • Context + useReducer()
    • Управление состоянием сложного компонента с помощью функции-редуктора и передача состояния вложенным компонентам без бурения

  • Redux
    • Управление очень сложным состоянием с помощью функций-редукторов
    • Прослеживаемость того, когда, почему и как менялось состояние в течение времени
    • Желание полной изоляции логики управления состоянием от слоя UI
    • Распределение логики управления состоянием между разными слоями UI
    • Использование возможностей middleware для реализации дополнительной логики при инициализации операций
    • Возможность сохранения определенных частей состояния
    • Возможность получения воспроизводимых отчетов об ошибках
    • Возможность быстрой отладки логики и UI в процессе разработки

  • Redux + React-Redux
    • Все случаи использования Redux + возможность взаимодействия React-компонентов с хранилищем Redux


Еще раз: названные инструменты решают разные задачи!

Рекомендации


Как же решить, что следует использовать?

Для этого вам всего лишь нужно определить, какой инструмент наилучшим образом решает задачи вашего приложения.

  • Если вам требуется просто избежать бурения, используйте контекст
  • Если у вас имеется сложное состояние, но вы не хотите использовать сторонние библиотеки, используйте контекст + useReducer()
  • Если вам требуется хорошая трассировка изменений состояния во времени, управлемый повторный рендеринг определенных компонентов, более мощные возможности обработки побочных эффектов и т.п., используйте Redux + React-Redux

Я считаю, что если в вашем приложении имеется 2-3 контекста для управления состоянием, то вам следует переключиться на Redux.

Часто можно услышать, что использование Redux предполагает написание большого количества шаблонного кода, однако, современный Redux значительно облегчает изучение данного инструмента и его использование. Официальный пакет Redux Toolkit решает проблему шаблонизации, а хуки React-Redux упрощают использование Redux в компонентах React.

Разумеется, добавление RTK и React-Redux в качестве зависимостей увеличивает бандл приложения по сравнению с контекстом + useReducer(), которые являются встроенными. Но преимущества такого подхода перекрывают недостатки лучшая трассировка состояния, простая и более предсказуемая логика, улучшенная оптимизация рендринга компонентов.

Также важно отметить, что одно не исключает другого вы можете использовать Redux, Context и useReducer() вместе. Мы рекомендуем хранить глобальное состояние в Redux, а локальное в компонентах и внимательно подходить к определению того, какая часть приложения должна храниться в Redux, а какая в компонентах. Так что вы можете использовать Redux для хранения глобального состояния, Context + useReducer() для хранения локального состояния, и Context для статических значений, одновременно и в одном приложении.

Еще раз: я не утверждаю, что все состояние приложения должно храниться в Redux или что Redux это всегда лучшее решение. Я утверждаю лишь, что Redux хороший выбор, существует много причин использовать Redux, и плата за его использование не так высока, как многие думают.

Наконец, контекст и Redux не единственные в своем роде. Существует множество других инструментов, решающих иные аспекты управления состоянием. MobX популярное решение, использующее ООП и наблюдаемые объекты (observables) для автоматического обновления зависимостей. Среди других подходов к обновлению состояния можно назвать Jotai, Recoil и Zustand. Библиотеки для работы с данными, вроде React Query, SWR, Apollo и Urql, предоставляют абстракции, упрощающие применение распространенных паттернов для работы с состоянием, кэшируемым сервером (скоро похожая библиотека (RTK Query) появится и для Redux Toolkit).

Надеюсь, данная статья помогла вам понять разницу между контекстом и Redux, а также какой инструмент и в каких случаях следует использовать. Благодарю за внимание.
Подробнее..

React основные подходы к управлению состоянием

12.03.2021 08:08:07 | Автор: admin


Доброго времени суток, друзья!

Предлагаю вашему вниманию простое приложение список задач. Что в нем особенного, спросите вы. Дело в том, что я попытался реализовать одну и ту же тудушку с использованием четырех разных подходов к управлению состоянием в React-приложениях: useState, useContext + useReducer, Redux Toolkit и Recoil.

Начнем с того, что такое состояние приложения, и почему так важен выбор правильного инструмента для работы с ним.

Состояние это собирательное понятие для любой информации, имеющей отношение к приложению. Это могут быть как данные, используемые в приложении, такие как тот же список задач или список пользователей, так и состояние как таковое, например, состояние загрузки или состояние формы.

Условно, состояние можно разделить на локальное и глобальное. Под локальным состоянием, обычно, понимается состояние отдельно взятого компонента, например, состояние формы, как правило, является локальным состоянием соответствующего компонента. В свою очередь, глобальное состояние правильнее именовать распределенным или совместно используемым, подразумевая под этим то, что такое состояние используется более чем одним компонентом. Условность рассматриваемой градации выражается в том, что локальное состояние вполне может использоваться несколькими компонентами (например, состояние, определенное с помощью useState(), может в виде пропов передаваться дочерним компонентам), а глобальное состояние не обязательно используется всеми компонентами приложения (например, в Redux, где имеется одно хранилище для состояния всего приложения, обычно, создается отдельный срез (slice) состояния для каждой части UI, точнее, для логики управления этой частью).

Важность выбора правильного инструмента для управления состоянием приложения обусловлена теми проблемами, которые возникают при несоответствии инструмента размерам приложения или сложности реализуемой в нем логики. Мы убедимся в этом в процессе разработки списка задач.

Я не буду вдаваться в подробности работы каждого инструмента, а ограничусь общим описанием и ссылками на соответствующие материалы. Для прототипирования UI будет использоваться react-bootstrap.

Код на GitHub
Песочница на CodeSandbox

Создаем проект с помощью Create React App:

yarn create react-app state-management# илиnpm init react-app state-management# илиnpx create-react-app state-management

Устанавливаем зависимости:

yarn add bootstrap react-bootstrap nanoid# илиnpm i bootstrap react-bootstrap nanoid

  • bootstrap, react-bootstrap стили
  • nanoid утилита для генерации уникального ID

В src создаем директорию use-state для первого варианта тудушки.

useState()


Шпаргалка по хукам

Хук useState() предназначен для управления локальным состоянием компонента. Он возвращает массив с двумя элементами: текущим значением состояния и сеттером функцией для обновления этого значения. Сигнатура данного хука:

const [state, setState] = useState(initialValue)

  • state текущее значение состояния
  • setState сеттер
  • initialValue начальное или дефолтное значение

Одним из преимуществ деструктуризации массива, в отличие от деструктуризации объекта, является возможность использования произвольных названий переменных. По соглашению, название сеттера должно начинаться с set + название первого элемента с большой буквы ([count, setCount], [text, setText] и т.п.).

Пока ограничимся четырьмя базовыми операциями: добавление, переключение (выполнение), обновление и удаление задачи, но усложним себе жизнь тем, что наше начальное состояние будет иметь форму нормализованных данных (это позволит как следует попрактиковаться в иммутабельном обновлении).

Структура проекта:

|--use-state  |--components    |--index.js    |--TodoForm.js    |--TodoList.js    |--TodoListItem.js  |--App.js

Думаю, тут все понятно.

В App.js мы с помощью useState() определяем начальное состояние приложения, импортируем и рендерим компоненты приложения, передавая им состояние и сеттер в виде пропов:

// хукimport { useState } from 'react'// компонентыimport { TodoForm, TodoList } from './components'// стилиimport { Container } from 'react-bootstrap'// начальное состояние// изучите его как следует, чтобы понимать логику обновленияconst initialState = {  todos: {    ids: ['1', '2', '3', '4'],    entities: {      1: {        id: '1',        text: 'Eat',        completed: true      },      2: {        id: '2',        text: 'Code',        completed: true      },      3: {        id: '3',        text: 'Sleep',        completed: false      },      4: {        id: '4',        text: 'Repeat',        completed: false      }    }  }}export default function App() {  const [state, setState] = useState(initialState)  const { length } = state.todos.ids  return (    <Container style={{ maxWidth: '480px' }} className='text-center'>      <h1 className='mt-2'>useState</h1>      <TodoForm setState={setState} />      {length ? <TodoList state={state} setState={setState} /> : null}    </Container>  )}

В TodoForm.js мы реализуем добавление новой задачи в список:

// хукimport { useState } from 'react'// утилита для генерации IDimport { nanoid } from 'nanoid'// стилиimport { Container, Form, Button } from 'react-bootstrap'// функция принимает сеттерexport const TodoForm = ({ setState }) => {  const [text, setText] = useState('')  const updateText = ({ target: { value } }) => {    setText(value)  }  const addTodo = (e) => {    e.preventDefault()    const trimmed = text.trim()    if (trimmed) {      const id = nanoid(5)      const newTodo = { id, text, completed: false }      // обратите внимание, как нам приходится обновлять состояние      setState((state) => ({        ...state,        todos: {          ...state.todos,          ids: state.todos.ids.concat(id),          entities: {            ...state.todos.entities,            [id]: newTodo          }        }      }))      setText('')    }  }  return (    <Container className='mt-4'>      <h4>Form</h4>      <Form className='d-flex' onSubmit={addTodo}>        <Form.Control          type='text'          placeholder='Enter text...'          value={text}          onChange={updateText}        />        <Button variant='primary' type='submit'>          Add        </Button>      </Form>    </Container>  )}

В TodoList.js мы просто рендерим список элементов:

// компонентimport { TodoListItem } from './TodoListItem'// стилиimport { Container, ListGroup } from 'react-bootstrap'// функция принимает состояние и сеттер только для того,// чтобы передать их потомкам// обратите внимание, как мы передаем отдельную задачуexport const TodoList = ({ state, setState }) => (  <Container className='mt-2'>    <h4>List</h4>    <ListGroup>      {state.todos.ids.map((id) => (        <TodoListItem          key={id}          todo={state.todos.entities[id]}          setState={setState}        />      ))}    </ListGroup>  </Container>)

Наконец, в TodoListItem.js происходит самое интересное здесь мы реализуем оставшиеся операции: переключение, обновление и удаление задачи:

// стилиimport { ListGroup, Form, Button } from 'react-bootstrap'// функция принимает задачу и сеттерexport const TodoListItem = ({ todo, setState }) => {  const { id, text, completed } = todo  // переключение задачи  const toggleTodo = () => {    setState((state) => {      // небольшая оптимизация      const { todos } = state      return {        ...state,        todos: {          ...todos,          entities: {            ...todos.entities,            [id]: {              ...todos.entities[id],              completed: !todos.entities[id].completed            }          }        }      }    })  }  // обновление задачи  const updateTodo = ({ target: { value } }) => {    const trimmed = value.trim()    if (trimmed) {      setState((state) => {        const { todos } = state        return {          ...state,          todos: {            ...todos,            entities: {              ...todos.entities,              [id]: {                ...todos.entities[id],                text: trimmed              }            }          }        }      })    }  }  // удаление задачи  const deleteTodo = () => {    setState((state) => {      const { todos } = state      const newIds = todos.ids.filter((_id) => _id !== id)      const newTodos = newIds.reduce((obj, id) => {        if (todos.entities[id]) return { ...obj, [id]: todos.entities[id] }        else return obj      }, {})      return {        ...state,        todos: {          ...todos,          ids: newIds,          entities: newTodos        }      }    })  }  // небольшой финт для упрощения обновления задачи  const inputStyles = {    outline: 'none',    border: 'none',    background: 'none',    textAlign: 'center',    textDecoration: completed ? 'line-through' : '',    opacity: completed ? '0.8' : '1'  }  return (    <ListGroup.Item className='d-flex align-items-baseline'>      <Form.Check        type='checkbox'        checked={completed}        onChange={toggleTodo}      />      <Form.Control        style={inputStyles}        defaultValue={text}        onChange={updateTodo}        disabled={completed}      />      <Button variant='danger' onClick={deleteTodo}>        Delete      </Button>    </ListGroup.Item>  )}

В components/index.js мы выполняем повторный экспорт компонентов:

export { TodoForm } from './TodoForm'export { TodoList } from './TodoList'

Файл scr/index.js выглядит следующим образом:

import React from 'react'import { render } from 'react-dom'// стилиimport 'bootstrap/dist/css/bootstrap.min.css'// компонентimport App from './use-state/App'const root$ = document.getElementById('root')render(<App />, root$)

Основные проблемы данного подхода к управлению состоянием:

  • Необходимость передачи состояния и/или сеттера на каждом уровне вложенности, обусловленная локальным характером состояния
  • Логика обновления состояния приложения разбросана по компонентам и смешана с логикой самих компонентов
  • Сложность обновления состояния, вытекающая из его иммутабельности
  • Однонаправленный поток данных, невозможность свободного обмена данными между компонентами, находящимися на одном уровне вложенности, но в разных поддеревьях виртуального DOM

Первые две проблемы можно решить с помощью комбинации useContext()/ useReducer().

useContext() + useReducer()


Шпаргалка по хукам

Контекст (context) позволяет передавать значения дочерним компонентам напрямую, минуя их предков. Хук useContext() позволяет извлекать значения из контекста в любом компоненте, обернутом в провайдер (provider).

Создание контекста:

const TodoContext = createContext()

Предоставление контекста с состоянием дочерним компонентам:

<TodoContext.Provider value={state}>  <App /></TodoContext.Provider>

Извлечение значения состония из контекста в компоненте:

const state = useContext(TodoContext)

Хук useReducer() принимает редуктор (reducer) и начальное состояние. Он возвращает значение текущего состояния и функцию для отправки (dispatch) операций (actions), на основе которых осуществляется обновление состояния. Сигнатура данного хука:

const [state, dispatch] = useReducer(todoReducer, initialState)

Алгоритм обновления состояния выглядит так: компонент отправляет операцию в редуктор, а редуктор на основе типа операции (action.type) и опциональной полезной нагрузки операции (action.payload) определенным образом изменяет состояния.

Результатом комбинации useContext() и useReducer() является возможность передачи состояния и диспетчера, возвращаемых useReducer(), любому компоненту, являющемуся потомком провайдера контекста.

Создаем директорию use-reducer для второго варианта тудушки. Структура проекта:

|--use-reducer  |--modules    |--components      |--index.js      |--TodoForm.js      |--TodoList.js      |--TodoListItem.js    |--todoReducer      |--actions.js      |--actionTypes.js      |--todoReducer.js    |--todoContext.js  |--App.js

Начнем с редуктора. В actionTypes.js мы просто определяем типы (названия, константы) операций:

const ADD_TODO = 'ADD_TODO'const TOGGLE_TODO = 'TOGGLE_TODO'const UPDATE_TODO = 'UPDATE_TODO'const DELETE_TODO = 'DELETE_TODO'export { ADD_TODO, TOGGLE_TODO, UPDATE_TODO, DELETE_TODO }

Типы операций определяются в отдельном файле, поскольку используются как при создании объектов операции, так и при выборе редуктора случая (case reducer) в инструкции switch. Существует другой подход, когда типы, создатели операции и редуктор размещаются в одном файле. Такой подход назвается утиной структурой файла.

В actions.js определяются так называемые создатели операций (action creators), возвращающие объекты определенной формы (для редуктора):

import { ADD_TODO, TOGGLE_TODO, UPDATE_TODO, DELETE_TODO } from './actionTypes'const createAction = (type, payload) => ({ type, payload })const addTodo = (newTodo) => createAction(ADD_TODO, newTodo)const toggleTodo = (todoId) => createAction(TOGGLE_TODO, todoId)const updateTodo = (payload) => createAction(UPDATE_TODO, payload)const deleteTodo = (todoId) => createAction(DELETE_TODO, todoId)export { addTodo, toggleTodo, updateTodo, deleteTodo }

В todoReducer.js определяется сам редуктор. Еще раз: редуктор принимает состояние приложения и операцию, отправленную из компонента, и на основе типа операции (и полезной нагрузки) выполняет определенные действия, приводящие к обновлению состояния. Обновление состояния выполняется точно также, как в предыдущем варианте тудушки, только вместо setState() редуктор возвращает новое состояние.

// утилита для генерации IDimport { nanoid } from 'nanoid'// типы операцийimport * as actions from './actionTypes'export const todoReducer = (state, action) => {  const { todos } = state  switch (action.type) {    case actions.ADD_TODO: {      const { payload: newTodo } = action      const id = nanoid(5)      return {        ...state,        todos: {          ...todos,          ids: todos.ids.concat(id),          entities: {            ...todos.entities,            [id]: { id, ...newTodo }          }        }      }    }    case actions.TOGGLE_TODO: {      const { payload: id } = action      return {        ...state,        todos: {          ...todos,          entities: {            ...todos.entities,            [id]: {              ...todos.entities[id],              completed: !todos.entities[id].completed            }          }        }      }    }    case actions.UPDATE_TODO: {      const { payload: id, text } = action      return {        ...state,        todos: {          ...todos,          entities: {            ...todos.entities,            [id]: {              ...todos.entities[id],              text            }          }        }      }    }    case actions.DELETE_TODO: {      const { payload: id } = action      const newIds = todos.ids.filter((_id) => _id !== id)      const newTodos = newIds.reduce((obj, id) => {        if (todos.entities[id]) return { ...obj, [id]: todos.entities[id] }        else return obj      }, {})      return {        ...state,        todos: {          ...todos,          ids: newIds,          entities: newTodos        }      }    }    // по умолчанию (при отсутствии совпадения со всеми case) редуктор возвращает состояние в неизменном виде    default:      return state  }}

В todoContext.js определяется начальное состояние приложения, создается и экспортируется провайдер контекста со значением состояния и диспетчером из useReducer():

// reactimport { createContext, useReducer, useContext } from 'react'// редукторimport { todoReducer } from './todoReducer/todoReducer'// создаем контекстconst TodoContext = createContext()// начальное состояниеconst initialState = {  todos: {    ids: ['1', '2', '3', '4'],    entities: {      1: {        id: '1',        text: 'Eat',        completed: true      },      2: {        id: '2',        text: 'Code',        completed: true      },      3: {        id: '3',        text: 'Sleep',        completed: false      },      4: {        id: '4',        text: 'Repeat',        completed: false      }    }  }}// провайдерexport const TodoProvider = ({ children }) => {  const [state, dispatch] = useReducer(todoReducer, initialState)  return (    <TodoContext.Provider value={{ state, dispatch }}>      {children}    </TodoContext.Provider>  )}// утилита для извлечения значений из контекстаexport const useTodoContext = () => useContext(TodoContext)

В этом случае src/index.js выглядит так:

// React, ReactDOM и стилиimport { TodoProvider } from './use-reducer/modules/TodoContext'import App from './use-reducer/App'const root$ = document.getElementById('root')render(  <TodoProvider>    <App />  </TodoProvider>,  root$)

Теперь у нас нет необходимости передавать состояние и функцию для его обновления на каждом уровне вложенности компонентов. Компонент извлекает состояние и диспетчера с помощью useTodoContext(), например:

import { useTodoContext } from '../TodoContext'// в компонентеconst { state, dispatch } = useTodoContext()

Операции отправляются в редуктор с помощью dispatch(), которому передается создатель операции, которому может передаваться полезная нагрузка:

import * as actions from '../todoReducer/actions'// в компонентеdispatch(actions.addTodo(newTodo))

Код компонентов
App.js:

// componentsimport { TodoForm, TodoList } from './modules/components'// stylesimport { Container } from 'react-bootstrap'// contextimport { useTodoContext } from './modules/TodoContext'export default function App() {  const { state } = useTodoContext()  const { length } = state.todos.ids  return (    <Container style={{ maxWidth: '480px' }} className='text-center'>      <h1 className='mt-2'>useReducer</h1>      <TodoForm />      {length ? <TodoList /> : null}    </Container>  )}

TodoForm.js:

// reactimport { useState } from 'react'// stylesimport { Container, Form, Button } from 'react-bootstrap'// contextimport { useTodoContext } from '../TodoContext'// actionsimport * as actions from '../todoReducer/actions'export const TodoForm = () => {  const { dispatch } = useTodoContext()  const [text, setText] = useState('')  const updateText = ({ target: { value } }) => {    setText(value)  }  const handleAddTodo = (e) => {    e.preventDefault()    const trimmed = text.trim()    if (trimmed) {      const newTodo = { text, completed: false }      dispatch(actions.addTodo(newTodo))      setText('')    }  }  return (    <Container className='mt-4'>      <h4>Form</h4>      <Form className='d-flex' onSubmit={handleAddTodo}>        <Form.Control          type='text'          placeholder='Enter text...'          value={text}          onChange={updateText}        />        <Button variant='primary' type='submit'>          Add        </Button>      </Form>    </Container>  )}

TodoList.js:

// componentsimport { TodoListItem } from './TodoListItem'// stylesimport { Container, ListGroup } from 'react-bootstrap'// contextimport { useTodoContext } from '../TodoContext'export const TodoList = () => {  const {    state: { todos }  } = useTodoContext()  return (    <Container className='mt-2'>      <h4>List</h4>      <ListGroup>        {todos.ids.map((id) => (          <TodoListItem key={id} todo={todos.entities[id]} />        ))}      </ListGroup>    </Container>  )}

TodoListItem.js:

// stylesimport { ListGroup, Form, Button } from 'react-bootstrap'// contextimport { useTodoContext } from '../TodoContext'// actionsimport * as actions from '../todoReducer/actions'export const TodoListItem = ({ todo }) => {  const { dispatch } = useTodoContext()  const { id, text, completed } = todo  const handleUpdateTodo = ({ target: { value } }) => {    const trimmed = value.trim()    if (trimmed) {      dispatch(actions.updateTodo({ id, trimmed }))    }  }  const inputStyles = {    outline: 'none',    border: 'none',    background: 'none',    textAlign: 'center',    textDecoration: completed ? 'line-through' : '',    opacity: completed ? '0.8' : '1'  }  return (    <ListGroup.Item className='d-flex align-items-baseline'>      <Form.Check        type='checkbox'        checked={completed}        onChange={() => dispatch(actions.toggleTodo(id))}      />      <Form.Control        style={inputStyles}        defaultValue={text}        onChange={handleUpdateTodo}        disabled={completed}      />      <Button variant='danger' onClick={() => dispatch(actions.deleteTodo(id))}>        Delete      </Button>    </ListGroup.Item>  )}


Таким образом, мы решили две первые проблемы, связанные с использованием useState() в качестве инструмента для управления состоянием. На самом деле, прибегнув к помощи одной интересной библиотеки, мы можем решить и третью проблему сложность обновления состояния. immer позволяет безопасно мутировать иммутабельные значения (да, я знаю, как это звучит), для этого достаточно обернуть редуктор в функцию produce(). Создадим файл todoReducer/todoProducer.js:

// утилита, предоставляемая immerimport produce from 'immer'import { nanoid } from 'nanoid'// типы операцийimport * as actions from './actionTypes'// сравните с "классической" реализацией редуктора// для обновления состояния используется draft - черновик исходного состоянияexport const todoProducer = produce((draft, action) => {  const {    todos: { ids, entities }  } = draft  switch (action.type) {    case actions.ADD_TODO: {      const { payload: newTodo } = action      const id = nanoid(5)      ids.push(id)      entities[id] = { id, ...newTodo }      break    }    case actions.TOGGLE_TODO: {      const { payload: id } = action      entities[id].completed = !entities[id].completed      break    }    case actions.UPDATE_TODO: {      const { payload: id, text } = action      entities[id].text = text      break    }    case actions.DELETE_TODO: {      const { payload: id } = action      ids.splice(ids.indexOf(id), 1)      delete entities[id]      break    }    default:      return draft  }})

Главное ограничение, накладываемое immer, состоит в том, что мы должны либо мутировать состояние напрямую, либо возвращать состояние, обновленное иммутабельно. Нельзя делать и то, и другое одновременно.

Вносим изменения в todoContext.js:

// import { todoReducer } from './todoReducer/todoReducer'import { todoProducer } from './todoReducer/todoProducer'// в провайдере// const [state, dispatch] = useReducer(todoReducer, initialState)const [state, dispatch] = useReducer(todoProducer, initialState)

Все работает, как и прежде, но код редуктора стало легче читать и анализировать.

Двигаемся дальше.

Redux Toolkit


Руководство по Redux Toolkit

Redux Toolkit это набор инструментов, облегчающий работу с Redux. Сам по себе Redux очень похож на то, что мы реализовали с помощью useContext() + useReducer():

  • Состояние всего приложения находится в одном хранилище (store)
  • Дочерние компоненты оборачиваются в Provider из react-redux, которому в виде пропа store передается хранилище
  • Редукторы (reducers) каждой части состояния объединяются с помощью combineReducers() в один корневой редуктор (root reducer), который передается при создании хранилища в createStore()
  • Компоненты подключаются к хранилищу с помощью connect() (+ mapStateToProps(), mapDispatchToProps()) и т.д.

Для реализации основных операций мы воспользуемся следующими утилитами из Redux Toolkit:

  • configureStore() для создания и настройки хранилища
  • createSlice() для создания частей состояния
  • createEntityAdapter() для создания адаптера сущностей

Чуть позже мы расширим функционал списка задач с помощью следующих утилит:

  • createSelector() для создания селекторов
  • createAsyncThunk() для создания преобразователей (thunk)

Также в компонентах мы будем использовать следующие хуки из react-redux: useDispatch() для получения доступа к диспетчеру и useSelector() для получения доступа к селекторам.

Создаем директорию redux-toolkit для третьего варианта тудушки. Устанавливаем Redux Toolkit:

yarn add @reduxjs/toolkit# илиnpm i @reduxjs/toolkit

Структура проекта:

|--redux-toolkit  |--modules    |--components      |--index.js      |--TodoForm.js      |--TodoList.js      |--TodoListItem.js  |--slices    |--todosSlice.js  |--App.js  |--store.js

Начнем с хранилища. store.js:

// утилита для создания хранилищаimport { configureStore } from '@reduxjs/toolkit'// редукторimport todosReducer from './modules/slices/todosSlice'// начальное состояниеconst preloadedState = {  todos: {    ids: ['1', '2', '3', '4'],    entities: {      1: {        id: '1',        text: 'Eat',        completed: true      },      2: {        id: '2',        text: 'Code',        completed: true      },      3: {        id: '3',        text: 'Sleep',        completed: false      },      4: {        id: '4',        text: 'Repeat',        completed: false      }    }  }}// хранилищеconst store = configureStore({  reducer: {    todos: todosReducer  },  preloadedState})export default store

В этом случае src/index.js выглядит так:

// React, ReactDOM & стили// провайдерimport { Provider } from 'react-redux'// основной компонентimport App from './redux-toolkit/App'// хранилищеimport store from './redux-toolkit/store'const root$ = document.getElementById('root')render(  <Provider store={store}>    <App />  </Provider>,  root$)

Переходим к редуктору. slices/todosSlice.js:

// утилиты для создания части состояния и адаптера сущностейimport {  createSlice,  createEntityAdapter} from '@reduxjs/toolkit'// создаем адаптерconst todosAdapter = createEntityAdapter()// инициализируем начальное состояние// получаем { ids: [], entities: {} }const initialState = todosAdapter.getInitialState()// создаем часть состоянияconst todosSlice = createSlice({  // уникальный ключ, используемый в качестве префикса при генерации создателей операции  name: 'todos',  // начальное состояние  initialState,  // редукторы  reducers: {    // данный создатель операции отправляет в редуктор операцию { type: 'todos/addTodo', payload: newTodo }    addTodo: todosAdapter.addOne,    // Redux Toolkit использует immer для обновления состояния    toggleTodo(state, action) {      const { payload: id } = action      const todo = state.entities[id]      todo.completed = !todo.completed    },    updateTodo(state, action) {      const { id, text } = action.payload      const todo = state.entities[id]      todo.text = text    },    deleteTodo: todosAdapter.removeOne  }})// экспортируем селектор для получения всех entities в виде массиваexport const { selectAll: selectAllTodos } = todosAdapter.getSelectors(  (state) => state.todos)// экспортируем создателей операцииexport const {  addTodo,  toggleTodo,  updateTodo,  deleteTodo} = todosSlice.actions// эскпортируем редукторexport default todosSlice.reducer

В компоненте для доступа к диспетчеру используется useDispatch(), а для отправки конкретной операции создатель операции, импортируемый из todosSlice.js:

import { useDispatch } from 'react-redux'import { addTodo } from '../slices/todosSlice'// в компонентеconst dispatch = useDispatch()dispatch(addTodo(newTodo))

Давайте немного расширим функционал нашей тудушки, а именно: добавим возможность фильтрации задач, кнопки для выполнения всех задач и удаления выполненных задач, а также некоторую полезную статистику. Также давайте реализуем получение списка задач с сервера.

Начнем с сервера.

В качестве fake API мы будем использовать JSON Server. Вот шпаргалка по работе с ним. Устанавливаем json-server и concurrently утилиту для выполнения двух и более команд:

yarn add json-server concurrently# илиnpm i json-server concurrently

Вносим изменения в раздел scripts package.json:

"server": "concurrently \"json-server -w db.json -p 5000 -d 1000\" \"yarn start\""

  • -w означает наблюдение за изменениями файла db.json
  • -p означает порт, по умолчанию запросы из приложения отправляются на порт 3000
  • -d задержка ответа от сервера

Создаем файл db.json в корневой директории проекта (state-management):

{  "todos": [    {      "id": "1",      "text": "Eat",      "completed": true,      "visible": true    },    {      "id": "2",      "text": "Code",      "completed": true,      "visible": true    },    {      "id": "3",      "text": "Sleep",      "completed": false,      "visible": true    },    {      "id": "4",      "text": "Repeat",      "completed": false,      "visible": true    }  ]}

По умолчанию все запросы из приложения отправляются на порт 3000 (порт, на котором запущен сервер для разработки). Для того, чтобы запросы отправлялись на порт 5000 (порт, на котором будет работать json-server), необходимо их проксировать. Добавляем в package.json следующую строку:

"proxy": "http://localhost:5000"

Запускаем сервер с помощью команды yarn server.

Создаем еще одну часть состояния. slices/filterSlice.js:

import { createSlice } from '@reduxjs/toolkit'// фильтрыexport const Filters = {  All: 'all',  Active: 'active',  Completed: 'completed'}// начальное состояние - отображать все задачиconst initialState = {  status: Filters.All}// состояние фильтраconst filterSlice = createSlice({  name: 'filter',  initialState,  reducers: {    setFilter(state, action) {      state.status = action.payload    }  }})export const { setFilter } = filterSlice.actionsexport default filterSlice.reducer

Вносим изменения в store.js:

// нам больше не требуется preloadedStateimport { configureStore } from '@reduxjs/toolkit'import todosReducer from './modules/slices/todosSlice'import filterReducer from './modules/slices/filterSlice'const store = configureStore({  reducer: {    todos: todosReducer,    filter: filterReducer  }})export default store

Вносим изменения в todosSlice.js:

import {  createSlice,  createEntityAdapter,  // утилита для создания селекторов  createSelector,  // утилита для создания преобразователей  createAsyncThunk} from '@reduxjs/toolkit'// утилита для выполнения HTTP-запросовimport axios from 'axios'// фильтрыimport { Filters } from './filterSlice'const todosAdapter = createEntityAdapter()const initialState = todosAdapter.getInitialState({  // добавляем в начальное состояние статус загрузки  status: 'idle'})// адрес сервераconst SERVER_URL = 'http://localhost:5000/todos'// преобразовательexport const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {  try {    const response = await axios(SERVER_URL)    return response.data  } catch (err) {    console.error(err.toJSON())  }})const todosSlice = createSlice({  name: 'todos',  initialState,  reducers: {    addTodo: todosAdapter.addOne,    toggleTodo(state, action) {      const { payload: id } = action      const todo = state.entities[id]      todo.completed = !todo.completed    },    updateTodo(state, action) {      const { id, text } = action.payload      const todo = state.entities[id]      todo.text = text    },    deleteTodo: todosAdapter.removeOne,    // создатель операции для выполнения всех задач    completeAllTodos(state) {      Object.values(state.entities).forEach((todo) => {        todo.completed = true      })    },    // создатель операции для очистки выполненных задач    clearCompletedTodos(state) {      const completedIds = Object.values(state.entities)        .filter((todo) => todo.completed)        .map((todo) => todo.id)      todosAdapter.removeMany(state, completedIds)    }  },  // дополнительные редукторы  extraReducers: (builder) => {    builder      // после начала выполнения запроса на получения задач      // меняем значение статуса на loading      // это позволит отображать индикатор загрузки в App.js      .addCase(fetchTodos.pending, (state) => {        state.status = 'loading'      })      // после получения задач от сервера      // записываем их в состояние      // и меняем статус загрузки      .addCase(fetchTodos.fulfilled, (state, action) => {        todosAdapter.setAll(state, action.payload)        state.status = 'idle'      })  }})export const { selectAll: selectAllTodos } = todosAdapter.getSelectors(  (state) => state.todos)// создаем и экспортируем кастомный селектор для получения отфильтрованных задачexport const selectFilteredTodos = createSelector(  selectAllTodos,  (state) => state.filter,  (todos, filter) => {    const { status } = filter    if (status === Filters.All) return todos    return status === Filters.Active      ? todos.filter((todo) => !todo.completed)      : todos.filter((todo) => todo.completed)  })export const {  addTodo,  toggleTodo,  updateTodo,  deleteTodo,  completeAllTodos,  clearCompletedTodos} = todosSlice.actionsexport default todosSlice.reducer

Вносим изменения в src/index.js:

// после импорта компонента "App"import { fetchTodos } from './redux-toolkit/modules/slices/todosSlice'store.dispatch(fetchTodos())

App.js выглядит так:

// хук для доступа к селекторамimport { useSelector } from 'react-redux'// индикатор загрузки - спиннерimport Loader from 'react-loader-spinner'// компонентыimport {  TodoForm,  TodoList,  TodoFilters,  TodoControls,  TodoStats} from './modules/components'// стилиimport { Container } from 'react-bootstrap'// селектор для получения всех entitites в виде массиваimport { selectAllTodos } from './modules/slices/todosSlice'export default function App() {  // получаем длину массива сущностей  const { length } = useSelector(selectAllTodos)  // получаем значение статуса  const loadingStatus = useSelector((state) => state.todos.status)  // стили для индикатора загрузки  const loaderStyles = {    position: 'absolute',    top: '50%',    left: '50%',    transform: 'translate(-50%, -50%)'  }  if (loadingStatus === 'loading')    return (      <Loader        type='Oval'        color='#00bfff'        height={80}        width={80}        style={loaderStyles}      />    )  return (    <Container style={{ maxWidth: '480px' }} className='text-center'>      <h1 className='mt-2'>Redux Toolkit</h1>      <TodoForm />      {length ? (        <>          <TodoStats />          <TodoFilters />          <TodoList />          <TodoControls />        </>      ) : null}    </Container>  )}

Код остальных компонентов
TodoControls.js:

// reduximport { useDispatch } from 'react-redux'// stylesimport { Container, ButtonGroup, Button } from 'react-bootstrap'// action creatorsimport { completeAllTodos, clearCompletedTodos } from '../slices/todosSlice'export const TodoControls = () => {  const dispatch = useDispatch()  return (    <Container className='mt-2'>      <h4>Controls</h4>      <ButtonGroup>        <Button          variant='outline-secondary'          onClick={() => dispatch(completeAllTodos())}        >          Complete all        </Button>        <Button          variant='outline-secondary'          onClick={() => dispatch(clearCompletedTodos())}        >          Clear completed        </Button>      </ButtonGroup>    </Container>  )}

TodoFilters.js:

// reduximport { useDispatch, useSelector } from 'react-redux'// stylesimport { Container, Form } from 'react-bootstrap'// filters & action creatorimport { Filters, setFilter } from '../slices/filterSlice'export const TodoFilters = () => {  const dispatch = useDispatch()  const { status } = useSelector((state) => state.filter)  const changeFilter = (filter) => {    dispatch(setFilter(filter))  }  return (    <Container className='mt-2'>      <h4>Filters</h4>      {Object.keys(Filters).map((key) => {        const value = Filters[key]        const checked = value === status        return (          <Form.Check            key={value}            inline            label={value.toUpperCase()}            type='radio'            name='filter'            onChange={() => changeFilter(value)}            checked={checked}          />        )      })}    </Container>  )}

TodoForm.js:

// reactimport { useState } from 'react'// reduximport { useDispatch } from 'react-redux'// libsimport { nanoid } from 'nanoid'// stylesimport { Container, Form, Button } from 'react-bootstrap'// action creatorimport { addTodo } from '../slices/todosSlice'export const TodoForm = () => {  const dispatch = useDispatch()  const [text, setText] = useState('')  const updateText = ({ target: { value } }) => {    setText(value)  }  const handleAddTodo = (e) => {    e.preventDefault()    const trimmed = text.trim()    if (trimmed) {      const newTodo = { id: nanoid(5), text, completed: false }      dispatch(addTodo(newTodo))      setText('')    }  }  return (    <Container className='mt-4'>      <h4>Form</h4>      <Form className='d-flex' onSubmit={handleAddTodo}>        <Form.Control          type='text'          placeholder='Enter text...'          value={text}          onChange={updateText}        />        <Button variant='primary' type='submit'>          Add        </Button>      </Form>    </Container>  )}

TodoList.js:

// reduximport { useSelector } from 'react-redux'// componentimport { TodoListItem } from './TodoListItem'// stylesimport { Container, ListGroup } from 'react-bootstrap'// selectorimport { selectFilteredTodos } from '../slices/todosSlice'export const TodoList = () => {  const filteredTodos = useSelector(selectFilteredTodos)  return (    <Container className='mt-2'>      <h4>List</h4>      <ListGroup>        {filteredTodos.map((todo) => (          <TodoListItem key={todo.id} todo={todo} />        ))}      </ListGroup>    </Container>  )}

TodoListItem.js:

// reduximport { useDispatch } from 'react-redux'// stylesimport { ListGroup, Form, Button } from 'react-bootstrap'// action creatorsimport { toggleTodo, updateTodo, deleteTodo } from '../slices/todosSlice'export const TodoListItem = ({ todo }) => {  const dispatch = useDispatch()  const { id, text, completed } = todo  const handleUpdateTodo = ({ target: { value } }) => {    const trimmed = value.trim()    if (trimmed) {      dispatch(updateTodo({ id, trimmed }))    }  }  const inputStyles = {    outline: 'none',    border: 'none',    background: 'none',    textAlign: 'center',    textDecoration: completed ? 'line-through' : '',    opacity: completed ? '0.8' : '1'  }  return (    <ListGroup.Item className='d-flex align-items-baseline'>      <Form.Check        type='checkbox'        checked={completed}        onChange={() => dispatch(toggleTodo(id))}      />      <Form.Control        style={inputStyles}        defaultValue={text}        onChange={handleUpdateTodo}        disabled={completed}      />      <Button variant='danger' onClick={() => dispatch(deleteTodo(id))}>        Delete      </Button>    </ListGroup.Item>  )}

TodoStats.js:

// reactimport { useState, useEffect } from 'react'// reduximport { useSelector } from 'react-redux'// stylesimport { Container, ListGroup } from 'react-bootstrap'// selectorimport { selectAllTodos } from '../slices/todosSlice'export const TodoStats = () => {  const allTodos = useSelector(selectAllTodos)  const [stats, setStats] = useState({    total: 0,    active: 0,    completed: 0,    percent: 0  })  useEffect(() => {    if (allTodos.length) {      const total = allTodos.length      const completed = allTodos.filter((todo) => todo.completed).length      const active = total - completed      const percent = total === 0 ? 0 : Math.round((active / total) * 100) + '%'      setStats({        total,        active,        completed,        percent      })    }  }, [allTodos])  return (    <Container className='mt-2'>      <h4>Stats</h4>      <ListGroup horizontal>        {Object.entries(stats).map(([[first, ...rest], count], index) => (          <ListGroup.Item key={index}>            {first.toUpperCase() + rest.join('')}: {count}          </ListGroup.Item>        ))}      </ListGroup>    </Container>  )}


Как мы видим, с появлением Redux Toolkit использовать Redux для управления состоянием приложения стало проще, чем комбинацию useContext() + useReducer() (невероятно, но факт), не считая того, что Redux предоставляет больше возможностей для такого управления. Однако, Redux все-таки рассчитан на большие приложения со сложным состоянием. Существует ли какая-то альтернатива для управления состоянием небольших и средних приложений, кроме useContext()/useReducer(). Ответ: да, существует. Это Recoil.

Recoil


Руководство по Recoil

Recoil это новый инструмент для управления состоянием в React-приложениях. Что значит новый? Это значит, что некоторые его API все еще находятся на стадии разработки и могут измениться в будущем. Однако, те возможности, которые мы будем использовать для создания тудушки, являются стабильными.

В основе Recoil лежат атомы и селекторы. Атом это часть состояния, а селектор часть производного состояния. Атомы создаются с помощью функции atom(), а селекторы с помощью функции selector(). Для извлечение значений из атомов и селекторов используются хуки useRecoilState() (для чтения и записи), useRecoilValue() (только для чтения), useSetRecoilState() (только для записи) и др. Компоненты, использующие состояние Recoil, должны быть обернуты в RecoilRoot. По ощущениям, Recoil представляет собой промежуточное звено между useState() и Redux.

Создаем директорию recoil для последнего варианта тудушки и устанавливаем Recoil:

yarn add recoil# илиnpm i recoil

Структура проекта:

|--recoil  |--modules    |--atoms      |--filterAtom.js      |--todosAtom.js    |--components      |--index.js      |--TodoControls.js      |--TodoFilters.js      |--TodoForm.js      |--TodoList.js      |--TodoListItem.js      |--TodoStats.js  |--App.js

Вот как выглядит атом списка задач:

// todosAtom.js// утилиты для создания атомов и селекторовimport { atom, selector } from 'recoil'// утилита для выполнения HTTP-запросовimport axios from 'axios'// адрес сервераconst SERVER_URL = 'http://localhost:5000/todos'// атом с состоянием для списка задачexport const todosState = atom({  key: 'todosState',  default: selector({    key: 'todosState/default',    get: async () => {      try {        const response = await axios(SERVER_URL)        return response.data      } catch (err) {        console.log(err.toJSON())      }    }  })})

Одной из интересных особенностей Recoil является то, что мы можем смешивать синхронную и асинхронную логику при создании атомов и селекторов. Он спроектирован таким образом, что у нас имеется возможность использовать React Suspense для отображения резервного контента до получения данных. Также у нас имеется возможность использовать предохранитель (ErrorBoundary) для перехвата ошибок, возникающих при создании атомов и селекторов, в том числе асинхронным способом.

В этом случае src/index.js выглядит так:

import React, { Component, Suspense } from 'react'import { render } from 'react-dom'// recoilimport { RecoilRoot } from 'recoil'// индикатор загрузкиimport Loader from 'react-loader-spinner'import App from './recoil/App'// предохранитель с официального сайта Reactclass ErrorBoundary extends Component {  constructor(props) {    super(props)    this.state = { error: null, errorInfo: null }  }  componentDidCatch(error, errorInfo) {    this.setState({      error: error,      errorInfo: errorInfo    })  }  render() {    if (this.state.errorInfo) {      return (        <div>          <h2>Something went wrong.</h2>          <details style={{ whiteSpace: 'pre-wrap' }}>            {this.state.error && this.state.error.toString()}            <br />            {this.state.errorInfo.componentStack}          </details>        </div>      )    }    return this.props.children  }}const loaderStyles = {  position: 'absolute',  top: '50%',  left: '50%',  transform: 'translate(-50%, -50%)'}const root$ = document.getElementById('root')// мы оборачиваем основной компонент приложения сначала в Suspense, затем в ErrorBoundaryrender(  <RecoilRoot>    <Suspense      fallback={        <Loader          type='Oval'          color='#00bfff'          height={80}          width={80}          style={loaderStyles}        />      }    >      <ErrorBoundary>        <App />      </ErrorBoundary>    </Suspense>  </RecoilRoot>,  root$)

Атом фильтра выглядит следующим образом:

// filterAtom.js// recoilimport { atom, selector } from 'recoil'// атомimport { todosState } from './todosAtom'export const Filters = {  All: 'all',  Active: 'active',  Completed: 'completed'}export const todoListFilterState = atom({  key: 'todoListFilterState',  default: Filters.All})// данный селектор использует два атома: атом фильтра и атом списка задачexport const filteredTodosState = selector({  key: 'filteredTodosState',  get: ({ get }) => {    const filter = get(todoListFilterState)    const todos = get(todosState)    if (filter === Filters.All) return todos    return filter === Filters.Completed      ? todos.filter((todo) => todo.completed)      : todos.filter((todo) => !todo.completed)  }})

Компоненты извлекают значения из атомов и селекторов с помощью указанных выше хуков. Например, код компонента TodoListItem выглядит так:

// хукimport { useRecoilState } from 'recoil'// стилиimport { ListGroup, Form, Button } from 'react-bootstrap'// атомimport { todosState } from '../atoms/todosAtom'export const TodoListItem = ({ todo }) => {  // данный хук - это как useState() для состояния Recoil  const [todos, setTodos] = useRecoilState(todosState)  const { id, text, completed } = todo  const toggleTodo = () => {    const newTodos = todos.map((todo) =>      todo.id === id ? { ...todo, completed: !todo.completed } : todo    )    setTodos(newTodos)  }  const updateTodo = ({ target: { value } }) => {    const trimmed = value.trim()    if (!trimmed) return    const newTodos = todos.map((todo) =>      todo.id === id ? { ...todo, text: value } : todo    )    setTodos(newTodos)  }  const deleteTodo = () => {    const newTodos = todos.filter((todo) => todo.id !== id)    setTodos(newTodos)  }  const inputStyles = {    outline: 'none',    border: 'none',    background: 'none',    textAlign: 'center',    textDecoration: completed ? 'line-through' : '',    opacity: completed ? '0.8' : '1'  }  return (    <ListGroup.Item className='d-flex align-items-baseline'>      <Form.Check type='checkbox' checked={completed} onChange={toggleTodo} />      <Form.Control        style={inputStyles}        defaultValue={text}        onChange={updateTodo}        disabled={completed}      />      <Button variant='danger' onClick={deleteTodo}>        Delete      </Button>    </ListGroup.Item>  )}

Код остальных компонентов
TodoControls.js:

// recoilimport { useRecoilState } from 'recoil'// stylesimport { Container, ButtonGroup, Button } from 'react-bootstrap'// atomimport { todosState } from '../atoms/todosAtom'export const TodoControls = () => {  const [todos, setTodos] = useRecoilState(todosState)  const completeAllTodos = () => {    const newTodos = todos.map((todo) => (todo.completed = true))    setTodos(newTodos)  }  const clearCompletedTodos = () => {    const newTodos = todos.filter((todo) => !todo.completed)    setTodos(newTodos)  }  return (    <Container className='mt-2'>      <h4>Controls</h4>      <ButtonGroup>        <Button variant='outline-secondary' onClick={completeAllTodos}>          Complete all        </Button>        <Button variant='outline-secondary' onClick={clearCompletedTodos}>          Clear completed        </Button>      </ButtonGroup>    </Container>  )}

TodoFilters.js:

// recoilimport { useRecoilState } from 'recoil'// stylesimport { Container, Form } from 'react-bootstrap'// filters & atomimport { Filters, todoListFilterState } from '../atoms/filterAtom'export const TodoFilters = () => {  const [filter, setFilter] = useRecoilState(todoListFilterState)  return (    <Container className='mt-2'>      <h4>Filters</h4>      {Object.keys(Filters).map((key) => {        const value = Filters[key]        const checked = value === filter        return (          <Form.Check            key={value}            inline            label={value.toUpperCase()}            type='radio'            name='filter'            onChange={() => setFilter(value)}            checked={checked}          />        )      })}    </Container>  )}

TodoForm.js:

// reactimport { useState } from 'react'// recoilimport { useSetRecoilState } from 'recoil'// libsimport { nanoid } from 'nanoid'// stylesimport { Container, Form, Button } from 'react-bootstrap'// atomimport { todosState } from '../atoms/todosAtom'export const TodoForm = () => {  const [text, setText] = useState('')  const setTodos = useSetRecoilState(todosState)  const updateText = ({ target: { value } }) => {    setText(value)  }  const addTodo = (e) => {    e.preventDefault()    const trimmed = text.trim()    if (trimmed) {      const newTodo = { id: nanoid(5), text, completed: false }      setTodos((oldTodos) => oldTodos.concat(newTodo))      setText('')    }  }  return (    <Container className='mt-4'>      <h4>Form</h4>      <Form className='d-flex' onSubmit={addTodo}>        <Form.Control          type='text'          placeholder='Enter text...'          value={text}          onChange={updateText}        />        <Button variant='primary' type='submit'>          Add        </Button>      </Form>    </Container>  )}

TodoList.js:

// recoilimport { useRecoilValue } from 'recoil'// componentsimport { TodoListItem } from './TodoListItem'// stylesimport { Container, ListGroup } from 'react-bootstrap'// atomimport { filteredTodosState } from '../atoms/filterAtom'export const TodoList = () => {  const filteredTodos = useRecoilValue(filteredTodosState)  return (    <Container className='mt-2'>      <h4>List</h4>      <ListGroup>        {filteredTodos.map((todo) => (          <TodoListItem key={todo.id} todo={todo} />        ))}      </ListGroup>    </Container>  )}

TodoStats.js:

// reactimport { useState, useEffect } from 'react'// recoilimport { useRecoilValue } from 'recoil'// stylesimport { Container, ListGroup } from 'react-bootstrap'// atomimport { todosState } from '../atoms/todosAtom'export const TodoStats = () => {  const todos = useRecoilValue(todosState)  const [stats, setStats] = useState({    total: 0,    active: 0,    completed: 0,    percent: 0  })  useEffect(() => {    if (todos.length) {      const total = todos.length      const completed = todos.filter((todo) => todo.completed).length      const active = total - completed      const percent = total === 0 ? 0 : Math.round((active / total) * 100) + '%'      setStats({        total,        active,        completed,        percent      })    }  }, [todos])  return (    <Container className='mt-2'>      <h4>Stats</h4>      <ListGroup horizontal>        {Object.entries(stats).map(([[first, ...rest], count], index) => (          <ListGroup.Item key={index}>            {first.toUpperCase() + rest.join('')}: {count}          </ListGroup.Item>        ))}      </ListGroup>    </Container>  )}


Заключение


Итак, мы с вами реализовали список задач с использованием четырех разных подходов к управлению состоянием. Какие выводы можно из всего этого сделать?

Я выскажу свое мнение, оно не претендует на статус истины в последней инстанции. Разумеется, выбор правильного инструмента для управления состоянием зависит от задач, решаемых приложением:

  • Для управления локальным состоянием (состоянием одного-двух компонентов; при условии, что эти два компонента тесно связаны между собой) используйте useState()
  • Для управления распределенным состоянием (состоянием двух и более автономных компонентов) или состоянием небольших и средних приложений используйте Recoil или сочетание useContext()/useReducer()
  • Обратите внимание, что если вам нужно просто передавать значения в глубоко вложенные компоненты, то вам вполне хватит useContext() (useContext() сам по себе не является инструментом для управления состоянием)
  • Наконец, для управления глобальным состоянием (состоянием всех или большинства компонентов) или состоянием сложного приложения используйте Redux Toolkit

Что касается MobX, то я слышал о нем много хорошего, но изучить как следует пока не успел.

Благодарю за внимание и хорошего дня.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru