От переводчика:
Представляю вольный перевод статьи о том, как реализовать эффективное решение для замены Redux контекстом React и хуками. Указание на ошибки в переводе или тексте приветствуются. Приятного просмотра.
С момента выхода нового Context API в React 16.3.0 многие люди задавали себе вопрос, достаточно ли хорош новый API, чтоб рассматривать его как замену Redux? Я думал о том же, но до конца не понимал даже после выхода версии 16.8.0 с хуками. Я стараюсь пользоваться популярными технологиями, не всегда понимая всего спектра проблем, которые они решают, так что я слишком сильно привык к Redux.
И вот так получилось, что я подписался на новостную рассылку от Кента Си Доддс (Kent C. Dodds’) и обнаружил несколько email на тему контекста и управлением состоянием. Я начал читать…. и читать… и спустя 5 блог постов что-то щелкнуло.
Чтобы понять все основные концепты стоящие за этим, мы сделаем кнопку, по клику на которую мы будем получить анекдоты с icanhazdadjoke и отображать их. Это небольшой, но достаточный пример.
Для подготовки, давайте начнем с двух, казалось бы, случайных советов.
Во-первых, позвольте представить моего друга console.count
:
console.count('Button')
// Button: 1
console.count('Button')
// Button: 2
console.count('App')
// App: 1
console.count('Button')
// Button: 3
Мы добавим вызов console.count
в каждый компонент, чтобы посмотреть сколько раз он ре-рендерится. Довольно прикольно, да?
Во-вторых, когда React компонент ре-рендерится, он не ре-рендерит контент, переданный как children
.
function Parent({ children }) {
const [count, setCount] = React.useState()
console.count('Parent')
return (
<div>
<button type="button" onClick={() => {
setCount(count => count + 1)
}}>
Force re-render
</button>
{children}
</div>
)
}
function Child() {
console.count('Child')
return <div />
}
function App() {
return (
<Parent>
<Child />
</Parent>
)
}
После нескольких кликов по кнопке, вы должны увидеть следующее содержимое в консоли:
Parent: 1
Child: 1
Parent: 2
Parent: 3
Parent: 4
Имейте это в виду, что это часто упускаемый способ улучшить производительность вашего приложения.
Теперь, когда мы готовы, давайте создадим скелет нашего приложения:
import React from 'react'
function Button() {
console.count('Button')
return (
<button type="button">
Fetch dad joke
</button>
)
}
function DadJoke() {
console.count('DadJoke')
return (
<p>Fetched dad joke</p>
)
}
function App() {
console.count('App')
return (
<div>
<Button />
<DadJoke />
</div>
)
}
export default App
Button
должна получить генератор действия (прим. Action Creator. Перевод взят из документации Redux на русском языке) который будет получать анекдот. DadJoke
должен получить состояние, и App
отобразить оба компонента используя контекст Provider.
Теперь создадим пользовательский компонент и назовем его DadJokeProvider
, который внутри себя будет управлять состоянием и оборачивать дочерние компоненты в Context Provider. Помните, что обновление его состояния не будет ре-рендерить все приложение благодаря упомянутой выше оптимизации children в React.
Итак, создадим файл и назовем его contexts/dad-joke.js
:
import React from 'react'
const DadJokeContext = React.createContext()
export function DadJokeContextProvider({ children }) {
const state = { dadJoke: null }
const actions = {
fetchDadJoke: () => {},
}
return (
<DadJokeContext.Provider value={{ state, actions }}>
{children}
</DadJokeContext.Provider>
)
}
Так же экспортируем 2 хука для получения значения из контекста.
export function useDadJokeState() {
return React.useContext(DadJokeContext).state
}
export function useDadJokeActions() {
return React.useContext(DadJokeContext).actions
}
Теперь мы уже можем реализовать это:
import React from 'react'
import {
DadJokeProvider,
useDadJokeState,
useDadJokeActions,
} from './contexts/dad-joke'
function Button() {
const { fetchDadJoke } = useDadJokeActions()
console.count('Button')
return (
<button type="button" onClick={fetchDadJoke}>
Fetch dad joke
</button>
)
}
function DadJoke() {
const { dadJoke } = useDadJokeState()
console.count('DadJoke')
return (
<p>{dadJoke}</p>
)
}
function App() {
console.count('App')
return (
<DadJokeProvider>
<Button />
<DadJoke />
</DadJokeProvider>
)
}
export default App
Вот! Спасибо API, который мы сделали, используя хуки. Мы больше не будем делать никаких изменений в этом файле на протяжении всего поста.
Начнем добавлять функционал в наш файл с контекстом, начиная с состояния DadJokeProvider
. Да, мы могли бы просто использовать хук useState
, но давайте вместо этого управлять нашим состоянием через reducer
, просто добавив хорошо известный и любимый нами функционал Redux
.
function reducer(state, action) {
switch (action.type) {
case 'SET_DAD_JOKE':
return {
...state,
dadJoke: action.payload,
}
default:
return new Error();
}
}
Теперь мы можем передать этот reducer в хук useReducer
и получить анекдоты с API:
export function DadJokeProvider({ children }) {
const [state, dispatch] = React.useReducer(reducer, { dadJoke: null })
async function fetchDadJoke() {
const response = await fetch('https://icanhazdadjoke.com', {
headers: {
accept: 'application/json',
},
})
const data = await response.json()
dispatch({
type: 'SET_DAD_JOKE',
payload: data.joke,
})
}
const actions = {
fetchDadJoke,
}
return (
<DadJokeContext.Provider value={{ state, actions }}>
{children}
</DadJokeContext.Provider>
)
}
Должно работать! Клик по кнопке должен получить и отображать шутки!
Давайте проверим консоль:
App: 1
Button: 1
DadJoke: 1
Button: 2
DadJoke: 2
Button: 3
DadJoke: 3
Оба компонента ре-рендерятся каждый раз, когда обновляется состояние, но только один из них реально использует его. Представьте себе реальное приложение, в котором сотни компонентов используют только действия. Было бы неплохо, если бы мы могли предоставить все эти необязательные ре-рендеры?
И тут мы вступаем на территорию относительного равенства, поэтому небольшое напоминание:
const obj = {}
// ссылка равна ссылке на саму себя console.log(obj === obj) // true
// новый объект не равен другому новому объекту
// Это 2 разный объекта
console.log({} === {}) // false
Компонент, использующий контекст, будет ре-рендериться каждый раз, когда значение этого контекста изменяется. Давайте рассмотрим значение нашего Context Provider:
<DadJokeContext.Provider value={{ state, actions }}>
Здесь мы создаем новый объект во время каждого ре-рендера, но это неизбежно, потому что новый объект будет создаваться каждый раз, когда мы будем выполнять действие (dispatch
), поэтому просто невозможно закешировать (memoize
) это значение.
И все это выглядит как конец истории, да?
Если посмотрим функцию fetchDadJoke
, единственное, что она использует из внешней области видимости это dispatch
, правильно? В общем, я собираюсь открыть вам небольшой секрет о функциях, созданных в useReducer
и useState
. Для краткости я буду использовать useState
в качестве примера:
let prevSetCount
function Counter() {
const [count, setCount] = React.useState()
if (typeof prevSetCount !== 'undefined') {
console.log(setCount === prevSetCount)
}
prevSetCount = setCount
return (
<button type="button" onClick={() => {
setCount(count => count + 1)
}}>
Increment
</button>
)
}
Нажмите на кнопку несколько раз и посмотрите в консоль:
true
true
true
Вы заметите, что setCount
одна та же функция для каждого рендера. Это так же применимо и для нашей dispatch
функции.
Это означает, что наша функция fetchDadJoke
не зависит от чего-либо, что меняется со временем, и не зависит ни от каких других генераторов действий, поэтому объект действий нужно создавать только один раз, при первом рендере:
const actions = React.useMemo(() => ({
fetchDadJoke,
}), [])
Теперь, когда у нас есть закешированный объект с действиями, можем ли мы оптимизировать значение контекста? Вообще, нет, потому что неважно как хорошо мы оптимизируем объект значений, нам все равно нужно каждый раз создавать новый из-за изменений состояния. Однако, что если мы вынесем объект действий из существующего контекст в новый? Кто сказал, что у нас может быть лишь один контекст?
const DadJokeStateContext = React.createContext()
const DadJokeActionsContext = React.createContext()
Мы можем объединить оба контекста в нашем DadJokeProvider
:
return (
<DadJokeStateContext.Provider value={state}>
<DadJokeActionsContext.Provider value={actions}>
{children}
</DadJokeActionsContext.Provider>
</DadJokeStateContext.Provider>
)
И подправить наши хуки:
export function useDadJokeState() {
return React.useContext(DadJokeStateContext)
}
export function useDadJokeActions() {
return React.useContext(DadJokeActionsContext)
}
И мы закончили! Серьезно, загрузите столько анекдотов, сколько хотите и убедитесь в этом сами.
App: 1
Button: 1
DadJoke: 1
DadJoke: 2
DadJoke: 3
DadJoke: 4
DadJoke: 5
Вот вы и реализовали свое собственное оптимизированное решение для управления состоянием! Вы можете создавать различные провайдеры, используя этот двухконтекстный шаблон для создания своего приложения, но и это еще не все, вы также можете рендерить один и тот же компонент провайдера несколько раз! Чтооо?! Да, попробуйте, рендер DadJokeProvider
в нескольких местах и смотрите, как ваша реализация управления состоянием легко масштабируется!
Дайте волю вашему воображению и пересмотрите, зачем вам действительно нужен Redux
.
Спасибо Кенту Си Доддс (Kent C. Dodds) за статьи о двухконтекстном шаблоне. Я нигде больше не видел его и мне кажется это меняет правила игры.
Прочтите следующие посты из блога Кента для получения дополнительной информации о тех концептах, о которых я говорил:
Когда использовать useMemo и useCallback
Как оптимизировать значение контекста
Как эффективно использовать React Context
Управление состояним приложения в React.
Один простой трюк для оптимизации ре-рендеров в React
Автор: Иван Калинин