Lesson 11: State Management - strvcom/frontend-academy-2022 GitHub Wiki

Speaker: Konstantin Lebedev

Resources


Lesson notes

As your application grows, the ability to share state amongst different components in your application will inevitably become an issue. In some cases prop-drilling (lifting the state up to the closest common parent and then passing it down as props) can be fine, but if it makes your components less reusable and more tightly coupled to their position within a component tree, then you may want to start using a global state management library or React's own Context API.

Two types of state

When dealing with global state, it's important to make a distinction between UI state and async state as they pose slightly different requirements and can be solved using different methods.

  • Application/UI state - exists only on the client, the client owns the data
  • Async state - persisted remotely, implies shared ownership, can become out of date

For dealing with UI state, we recommend using Context API (for applications with little state) or a state management library like Redux, Zustand, Jotai, etc. (for applications that have big and complex UI state)

For dealing with async state, we recommend using React-query for REST APIs and Apollo Client for GraphQL.

Context API

To use Context API, we need to:

  1. Create the context
  2. Feed it data
  3. Plug it into our component tree
  4. Subscribe to it from the components that need that data

Create the context:

export type ContextValue = {
  view: ViewType
  setView: (view: ViewType) => void
  filter: FilterType
  setFilter: (filter: FilterType) => void
}

export const DashboardContext = createContext<ContextValue>({
  view: ViewType.GRID,
  setView: () => {},
  filter: FilterType.ALL,
  setFilter: () => {},
})

export const useDashboardContext = () => {
  return useContext(DashboardContext)
}

Feed it data:

export const DashboardContextProvider: FC<{ children: ReactNode }> = ({
  children,
}) => {
  const [view, setView] = useState<ViewType>(ViewType.GRID)
  const [filter, setFilter] = useState<FilterType>(FilterType.ALL)

  // always memo the context value to avoid re-rendering all the time
  const value = useMemo(
    () => ({
      view,
      setView,
      filter,
      setFilter,
    }),
    [view, filter]
  )

  return (
    <DashboardContext.Provider value={value}>
      {children}
    </DashboardContext.Provider>
  )
}

Plug it into the component tree:

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <>
      <GlobalStyle />
      <HeadDefault />
      <DashboardContextProvider>
        <Component {...pageProps} />
      </DashboardContextProvider>
    </>
  )
}

Use it from the components:

const { filter, setFilter, view, setView } = useDashboardContext()

Context API gotchas

  • when part of the context changes, all components subscribed to the context will re-render (regardless of whether they're subscribed to the part of the context that changed or not)
  • it scales poorly as the amount of managed global state grows

React-query

It lets use fetch, cache, and update data in your React applications all without touching any "global state".

Usage example:

const result = useQuery<Event[], Error>('events', async () => {
    const response = await api.get('/events')

    // to handle error state correctly, react-query needs to throw an error in case of unsuccessful responses (fetch doesn't do that by default)
    if (!response.ok) {
      throw new Error(`Failed to load events`)
    }

    return (await response.json()) as Event[]
  })
⚠️ **GitHub.com Fallback** ⚠️