Дисклеймер
В данной работе могут быть разного рода ошибки, поэтому их не нужно принимать за истину, это всего лишь кодец на основании опыта автора и ничего больше.
Так же, если не понимаете о чем идет речь, ат олл, пожалуйста посмотрите на эти ресурсы: createContext, zustand, Passing Data Deeply with Context.
Введение
React Context - это потужный инструмент, но использовать его бездумно опасно. Он решает проблему prop-dtilling и отлично работает с Compound Components, но может превратиться в бутылочное горлышко для производительности. В этой статье разбеем, где контекст реально помогает, какие подводные камни вас ждут и как избежать лишних ре-рендеров, сохранив код чистым и гибким.
Для чего использовать контекст?
Если вы никогда не работали с контекстом, то у меня для вас плохие новости, вы не работали с самым мощным инструментом React, по мнению автора.
Контекст позволяет внедрять зависимости в ваше дерево компонентов, и как следствие позволяет избегать вложение пропсов на каждом уровне вложенности. Так же вся сила React Context проявляется с использованием паттерна составных компонентов (Compound Component Pattern).
Definition
Compound Components - это шаблон проектирования, широко используемый в React. В этом паттерне родительский компонент управляет общим состоянием и логикой всего компонента, а так же инжектирует её в дочерние компоненты, которые совместно формируют полноценный элемент пользовательского интерфейса. Этот шаблон создаёт более гибкий, многоразовый и выразительный API, чем передача множества свойств одному компоненту, что упрощает настройку и компоновку разработчиком с помощью компонента.
То есть все взаимодействие внутри Compound Components осуществляется, как правило, с помощью React Context.
Пример использования контекста
Давайте рассмотрим на простом примере мадального окна.
Сначало создаем интерфейс, который описывает тип и сам контекст:
export interface ModalContextValue { shown: boolean onShow: () => void onClose: () => void}
export const ModalContext = createContext<ModalContextValue | null>(null)Затем создадим провайдер для данного контекста:
export function ModalProvider(props: ModalProviderProps & PropsWithChildren) { const [shown, setShown] = useState(props.defaultShown ?? false)
const onShow = () => { setShown(true) props.onChange?.(true) }
const onClose = () => { setShown(false) props.onChange?.(false) }
const value: ModalContextValue = { shown, onShow, onClose, }
return <ModalContext value={value} children={props.children} />}
export function withModalProvider<TProps extends JSX.IntrinsicAttributes>() { return (Component: ComponentType<TProps>) => { return (props: TProps) => ( <ModalProvider> <Component {...props} /> </ModalProvider> ) }}А так же создадим вспомогательных хук, который упрощает работу с контекстом в компонентах консьюмера:
export function useModal() { const context = useContext(ModalContext)
if (context === null) { throw new Error('The useModal hook must be used under the ModalProvider') }
return context}Осталось имплементировать сами компоненты модального окна. Для простоты понимания некоторые компоненты, которые отвечают за layout (header, body, footer, overlay), не были отображены.
export const Modal = () => null
Modal.Root = ModalProvider
Modal.Trigger = function ModalTrigger(props: ComponentProps<'button'>) { const { onShow } = useModal()
return <button onClick={onShow} {...props} />}
Modal.CloseButton = function ModalCloseButton(props: ComponentProps<'button'>) { const { onClose } = useModal()
return <button onClick={onClose} {...props} />}
Modal.Content = function ModalContent(props: ComponentProps<'div'>) { const { shown } = useModal()
return shown ? <div data-shown={shown} {...props} /> : null}Теперь можна использовать компонент, c легкостью можна изменять способы работы модального окна:
export default function IndexPage() { const onDisagreeClick = () => { /* redirect back */ }
return ( <main> <Modal.Root defaultShown={true}> <Modal.Content> <b>Are you agree with out rules?</b>
<div data-footer> <button onClick={onDisagreeClick}>No, disagree</button> <Modal.CloseButton>Yes, I totally agree!</Modal.CloseButton> </div> </Modal.Content> </Modal.Root>
<section data-some-content>...</section> </main> )}Или так:
export function ProfileSettingsModal() { const onSaveChanges = () => { /* Save changes */ }
return ( <Modal.Root> <Modal.Trigger> <Icon name="preferences" /> Settings </Modal.Trigger>
<Modal.Content> {/* User profile settings form */}
<div data-footer> <Modal.CloseButton>Cancel</Modal.CloseButton> <button onClick={onSaveChanges}>Save</button> </div> </Modal.Content> </Modal.Root> )}Как видно из примера, модальное окно можна использовать по разному, при этом сохраняя локаничность и простоту.
Только есть один нюанс, когда неоходимо работать с множеством Compound Components, которые используют контекст, например, dialogs, tabs, accordions, cards, select boxs, compobox, etc., создается много бойлерплейта, которого можна избежать.
Удобная работа с React Context
Можна создать фабрику контекстов, которая будет создавать сам контекст. провайдер и функцию обертку для провайдера, что минимизирует весь бойлерплейт.
Вот пример данной функции:
import type { ComponentType, Context, JSX, PropsWithChildren, ReactNode,} from 'react'import { createContext as createReactContext, useContext } from 'react'import type { MinLength } from './string-util-types'
const DISPLAY_NAME_MIN_LENGTH = 2
export function createContext<DisplayName extends string = string>( displayName: MinLength<DisplayName, typeof DISPLAY_NAME_MIN_LENGTH>,) { /** * TODO: You can add different type guards here, * such as minLengthOrThrow(displayName, DISPLAY_NAME_MIN_LENGTH) or capitalizeOrThrow(displayName) */
return function inner< ContextValue, ProviderProps = never | PropsWithChildren, >(contextValueCallback: (providerProps?: ProviderProps) => ContextValue) { const contextName = `${displayName as DisplayName}Context` as const const providerName = `${displayName as DisplayName}Provider` as const const withProviderName = `with${providerName}` as const const hookName = `use${displayName as DisplayName}` as const
const Context = createReactContext<ContextValue | null>(null) Context.displayName = contextName
function ContextProvider( providerProps: ProviderProps & { children: ReactNode }, ) { const contextValue = contextValueCallback(providerProps)
return ( <Context.Provider value={contextValue} children={providerProps.children} /> ) }
function withContextProvider<ChildrenProps>(providerProps?: ProviderProps) { return (Component: ComponentType<ChildrenProps>) => (props: ChildrenProps & JSX.IntrinsicAttributes) => { const contextValue = contextValueCallback(providerProps)
return ( <Context.Provider value={contextValue}> <Component {...props} /> </Context.Provider> ) } }
function useContextHook() { const value = useContext(Context)
if (value === null) { throw new Error(`The ${hookName} hook must be under ${providerName}`) }
return value as ContextValue }
return { [contextName]: Context as Context<ContextValue>, [providerName]: ContextProvider, [withProviderName]: withContextProvider, [hookName]: useContextHook, } as { [Key in typeof contextName]: Context<ContextValue> } & { [Key in typeof providerName]: typeof ContextProvider } & { [Key in typeof withProviderName]: typeof withContextProvider } & { [Key in typeof hookName]: typeof useContextHook } }}Теперь, исли создания контекста и всей инфраструктурных элементов, для модального окна, делается так:
export interface ModalContextValue { shown: boolean onShow: () => void onClose: () => void}
const modalContext = createContext('Modal')<ModalContextValue>(() => { const [shown, setShown] = useState(false)
return { shown, onShow: () => setShown(true), onClose: () => setShown(true), }})
export const { ModalProvider, withModalProvider, useModal, ModalContext,} = modalContextКак видно из примера, API никак не изменился, и модальное окно можна использовать как и раньше. Так же, с помощью данной функции, можна задекларировать пропсы для провайдера, например:
8 collapsed lines
import { useState } from 'react'import { createContext } from './react-create-context'
export interface ModalContextValue { shown: boolean onShow: () => void onClose: () => void}
export interface ModalProviderProps { defaultShown?: boolean onChange?: (shown: boolean) => void}
const modalContext = createContext('Modal')< ModalContextValue, ModalProviderProps>((props) => { const [shown, setShown] = useState(props?.defaultShown ?? false)
return { shown, onShow: () => { props?.onChange?.(true) setShown(true) }, onClose: () => { props?.onChange?.(false) setShown(true) }, }})
6 collapsed lines
export const { ModalProvider, withModalProvider, useModal, ModalContext,} = modalContextТеперь, модальному окну можно прокинуть пропсы, и изменить логику её работы без боли. Также, такой метод генерации хуков используеться в популярной библеотеке как RTK Query (Redux Tool Kit Query):
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'import type { Pokemon } from './types'
// Define a service using a base URL and expected endpointsexport const pokemonApi = createApi({ reducerPath: 'pokemonApi', baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }), endpoints: (build) => ({ getPokemonByName: build.query<Pokemon, string>({ query: (name) => `pokemon/${name}`, }), }),})
// Export hooks for usage in functional components, which are// auto-generated based on the defined endpointsexport const { useGetPokemonByNameQuery } = pokemonApi
export default function IndexPage() { // Using a query hook automatically fetches data and returns query values const { data, error, isLoading } = useGetPokemonByNameQuery('bulbasaur') // Individual hooks are also accessible under the generated endpoints: // const { data, error, isLoading } = pokemonApi.endpoints.getPokemonByName.useQuery('bulbasaur')
// render UI based on data and loading state}Минусы React Context и как их избежать
Минусы:
- Глобальный ре-рендер потребителей: Когда обновляется значение в
Context.Provider, перерисовываются все компоненты, которые используют этот контекст черезuseContext, даже если они не зависят от обновлённого поля. Это может привести к заметным проблемам с производительностью в больших приложениях. - Невидимые зависимости. Контекст может усложнить понимание кода: компонент явно не получает пропсы, но внутри использует
useContext, понять, откуда пришли данные труднее чем с обычнми пропсами. - Ограниченная область применения. Контекст плохо подходит для часто обновляемых данных (например, анимаций, быстро меняющихся значений).
Как избегать:
- Для решения проблемы лишних ре-рендеров можна разбивать контексты на более мелкие, например по геттерам и сеттерам, так как ссылка на сеттер всегда стабильна, компоненты которое меняют значение, не будут перерисовываься из-за изменения самих значений. Такох подход может сработать, но есть решение лучше, например использования библеотек, таких как
use-context-selector,zustandилиjotai. - Чтоб зависимости всегда оставались явными, контекст предпочтительно использовать по модулям/компонентам. А для взаимодействия между страницами/разными виджетами, лучше воспользоваться стейт менеджером. Так же на ясность влияет хороший нейминг, например, если сравнить useModal и useCreateBuildingModal, то сразу понятно что первый хук отвечает за generic modal, а второй за конкретный flow создания какого-то здания (определяется бизнес доменом).
- Действительно, контекст это не панацея, а просто инструментом, и его нужно использовать с умом, как и все в нашей жизни 😊.
Реальный пример
Недавно, на моем основном проекте, заказчику нужно было сделать компонент который является легковесной версией Excel ну или аналогом данной библиотеки Handsontable.
Чуток требований для понимания:
- Пользователь может редактировать текст в каждой ячейке гриды
- Пользователь может менять форматирования/выравнивание текста
- Пользователь может применять изменения для нескольких ячеек одновременно
- Пользователь может обьеденять несколько ячеек между собой
- Пользователь может добавлять/удалять столбцы/строки
- Выделенные и активная ячейки выделены разными цветами
- Пользователь может применять действия с помощью Control Panel and Context Menu
- Некоторые действия с компонентом ограничены по пользовательской роли
Для реализации данного компонета, был выбран React Context как DI Container, а так же zustand, чтоб избежать лишних ререндеров. В этой связке, zustand предоставляет удобный API для работы со стором, а Context инкапсулирует стор, и внедряет его во все дочерние компоненты.
Базовая Реализация:
import { StoreApi } from 'zustand'import { GridItemData, type GridState } from '../grid-state'import { Point, Rectangle } from '../primitives'
export type GridStoreContextValue = {32 collapsed lines
gridState: GridState loadGridState: (gridState: GridState) => void
getGridItem: (point: Point) => GridItemData | null setGridItemData: (point: Point, values: Partial<GridItemData>) => void
addRow: (index?: number) => void addCol: (index?: number) => void
removeRow: (index?: number, amount?: number) => void removeCol: (index?: number, amount?: number) => void
merge: () => void unmerge: () => void getIsContainsMergedCells: () => boolean
getMergeParams: (point: Point) => { colSpan: number rowSpan: number isIgnored: boolean }
selection: null | Rectangle setStartSelection: (point: Point) => void updateEndSelection: (point: Point) => void resetSelection: () => void getIsSelectedCell: (point: Point) => boolean
activeCell: null | Point setActiveCell: (point: Point) => void resetActiveCell: () => void getIsActiveCell: (point: Point) => boolean}
export type GridStoreContextType = StoreApi<GridStoreContextValue> | null
export type GridStoreListener = Parameters< StoreApi<GridStoreContextValue>['subscribe']>[0]Где GridStoreContextValue это тиа самого стора, а GridStoreContextType это тип который инжектируется в контекст.
Так выглядят создание контекста:
import { createContext } from "react";import { GridStoreContextType } from "./types";
export const GridStoreContext = createContext<GridStoreContextType>(null);И вспомогательне хуки, которые будут внедряться в дочерние компоненты:
import { createContext } from 'react'import { GridStoreContextType } from './types'
export const GridStoreContext = createContext<GridStoreContextType>(null)import { useContext } from 'react'import { useStoreWithEqualityFn } from 'zustand/traditional'import { GridStoreContext } from './grid-store-context'import { GridStoreContextType } from './types'
type ExtractState<S> = S extends { getState: () => infer T } ? T : never
type StoreSelector<TResult> = ( state: ExtractState<NonNullable<GridStoreContextType>>,) => TResult
export function useGridStore<TResult>( selector: StoreSelector<TResult>, equalityFn?: (a: TResult, b: TResult) => boolean,) { const store = useContext(GridStoreContext)
if (!store) { throw new Error('Missing GridStoreProvider') }
return useStoreWithEqualityFn(store, selector, equalityFn)}Тут используется useStoreWithEqualityFn, а не useStore чтоб можна было прокинуть функцию сравнения для сложных обьектов, например:
import { shallow } from 'zustand/shallow'import { useGridStore } from './data-grid-hook'
export const useGridSelection = () => useGridStore( (store) => store.selection, (prev, next) => shallow(prev?.p1, next?.p1) && shallow(prev?.p2, next?.p2), )Затем, инжектируем zustand store instance в контекст:
export function GridStoreProvider(props: GridStoreProviderProps) { const { children, defaultGridState } = props
const storeRef = useRef<GridStoreContextType>(null)
if (storeRef.current === null) { storeRef.current = createStore<GridStoreContextValue>((set, get) => ({28 collapsed lines
// Implementation is not provided gridState: { ...defaultGridState }, loadGridState: (gridState) => {},
getGridItem: (point) => {}, setGridItemData: (point, values) => {},
addRow: (insertIndex) => {}, addCol: (insertIndex) => {},
removeRow: (removeIndex, amount = 1) => {}, removeCol: (removeIndex, amount = 1) => {},
merge: () => {}, unmerge: () => {}, getIsContainsMergedCells: () => {},
selection: null, setStartSelection: (point) => {}, updateEndSelection: (point) => {}, resetSelection: () => {}, getIsSelectedCell: (point) => {},
activeCell: null, setActiveCell: (point) => {}, resetActiveCell: () => {},
getIsActiveCell: (point) => {}, })) }
return ( <GridStoreContext.Provider value={storeRef.current}> {children} </GridStoreContext.Provider> )}Инстанс стора создается и сохраняется в ref, чтоб ссылка на стор была стабильна.
Затем необходимо создать разные компоненты, такие как DataGrid.ContextMenu, DataGrid.ControlPanel, DataGrid.EventHandler, DataGrid.Cell, etc. Где каждый дочерний компонент использует вспомогательный хук, например:
export function GridCell(props: GridItemProps) { const { point, className, ...restProps } = props
const setGridItemData = useGridStore((store) => store.setGridItemData) const isEditing = useGridStore((store) => store.getIsActiveCell(point)) const gridItem = useGridItem(point)
const onChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback( (event) => setGridItemData(point, { value: event.target.value }), [point], )
return ( <div className={cn(s.wrapper, className)} {...restProps}> {/* Implementation is not provided */} </div> )}Или элемент контрольной панели, для редактирования количества строк:
export function RowsControls() { const removeRow = useGridStore((store) => store.removeRow) const addRow = useGridStore((store) => store.addRow)
const minHeight = useGridStore((store) => store.gridState.minHeight) const height = useGridStore((store) => store.gridState.height)
return ( <Button.Group size="small" className={cn(s.group, s.rangeGroup)}> <Button variant="secondary">Rows</Button> <Button onClick={() => removeRow()} disabled={height <= minHeight}> - </Button> <Button onClick={() => addRow()}>+</Button> </Button.Group> )}Так же можна скомбинировать внедрение zustand store в фабрику createReactContext, и получить минимум бойлерплейта.
Итоги
Таким образом, React Context + Compound Components ( + any atomic state manager if needed) = имба лютейшого масштаба хорошое решение для разработки инфраструктурных компонентов.
React Context - инжектирует зависимости в дочерние компоненты, инкапсулирует internal состояние компонента от чужих глаз.
Compound Component - группирует компоненты по смыслу и логике.
Atomic state managers - исправляют недостатки контекста, улучшают перформанс для сложных компонентов.
Иф ю хеав ани квешонс, плиз контакт виз афтор изинг зе имейл, сенк ю а лот! ❤️