DEV Community

Cover image for React Context - jak efektywnie go używać?
Bartosz Zagrodzki
Bartosz Zagrodzki

Posted on

React Context - jak efektywnie go używać?

Przetłumaczona wersja artykułu Kenta C. Doddsa

We wpisie o zarządzaniu stanem aplikacji w React wspominałem, jak używanie stanu lokalnego wraz z kontekstem może pomóc w zarządzaniu stanem dowolnej aplikacji. Pokazałem tam kilka przykładów do których chciałbym się teraz odnieść, by przedstawić jak efektywnie tworzyć konsumentów kontekstu, unikając problemów, a jednocześnie poprawiając czytelność kodu i ułatwiając jego utrzymanie dla Twoich aplikacji i/lub bibliotek.

Zapoznaj się z zarządzaniem stanu w React i postępuj zgodnie z radą, mówiącą, że nie powinieneś używać kontekstu do rozwiązania każdego problemu ze stanem w aplikacji. Ale kiedy już będziesz zmuszony po niego sięgnąć, mam nadzieję, że ten wpis pomoże ci się dowiedzieć jak skutecznie to zrobić. Pamiętaj, że kontekst NIE musi być globalny dla całej aplikacji, ale może być zastosowany do jednej jej części i możesz (a prawdopodobnie powinieneś) mieć wiele logicznie oddzielonych od siebie kontekstów.

Najpierw, stwórzmy plik src/count-context.js, a w nim kontekst:

import * as React from 'react'

const CountContext = React.createContext()
Enter fullscreen mode Exit fullscreen mode

Po pierwsze, nie mam tutaj domyślnej wartości dla kontekstu. Jeśli chciałbym ją dodać musiałbym zrobić coś takiego: React.createContext({count: 0}). Zrobiłem to jednak celowo. Dodawanie wartości domyślnej jest użyteczne tylko w takim przypadku:

function CountDisplay() {
  const {count} = React.useContext(CountContext)
  return <div>{count}</div>
}

ReactDOM.render(<CountDisplay />, document.getElementById('⚛️'))
Enter fullscreen mode Exit fullscreen mode

Ponieważ nie mamy wartości domyślnej, otrzymamy błąd w linii, gdzie destrukturyzujemy wartość zwracaną z useContext(). Dzieje się tak, gdyż nie możemy destrukturyzować undefined, a właśnie taką wartość domyślną ma nasz kontekst.

Nikt z nas nie lubi takich sytuacji, więc Twoją odruchową reakcją może być dodanie domyślnej wartości, aby uniknąć błędu. Jaki byłby jednak pożytek z kontekstu, gdyby nie wskazywał on na aktualny stan? Używając tylko domyślnych wartości, nie mógłby wiele zdziałać. W 99% przypadków, gdy będziesz tworzyć i używać kontekstu w swojej aplikacji, chciałbyś aby komponenty-konsumenci (korzystający z useContext()) były renderowane w ramach rodzica-dostawcy, który może zapewnić użyteczną wartość.

Istnieją sytuacje w których wartości domyślne są przydatne, lecz w większości przypadków tak nie jest.

Dokumentacja Reacta sugeruje, że podanie wartości domyślnej "przydaje się podczas testowania komponentów w izolacji, ponieważ nie ma konieczności opakowywania ich w sztucznych dostawców". Chociaż prawdą jest, że pozwala to zrobić, nie zgadzam się, że jest to lepsze od zapewniania komponentom niezbędnego kontekstu. Pamiętaj, że za każdym razem, gdy robisz w teście coś, czego nie ma w aplikacji, zmniejszasz pewność, jaką może dać Ci test. Istnieją powody, by tak robić, ale to nie jest jeden z nich.

Jeśli używasz TypeScript-u, brak wartości domyślnej może być naprawdę irytujący dla osób korzystających z React.useContext, ale pokażę ci, jak uniknąć tego problemu. Czytaj dalej!

Ok, kontynuujmy. Aby ten moduł kontekstu był w ogóle użyteczny, musimy użyć Provider-a i udostępnić komponent, który dostarcza wartość. Nasz komponent będzie używany w następujący sposób:

function App() {
  return (
    <CountProvider>
      <CountDisplay />
      <Counter />
    </CountProvider>
  )
}

ReactDOM.render(<App />, document.getElementById('⚛️'))
Enter fullscreen mode Exit fullscreen mode

Stwórzmy więc komponent, którego można użyć w ten sposób:

import * as React from 'react'

const CountContext = React.createContext()

function countReducer(state, action) {
  switch (action.type) {
    case 'increment': {
      return {count: state.count + 1}
    }
    case 'decrement': {
      return {count: state.count - 1}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function CountProvider({children}) {
  const [state, dispatch] = React.useReducer(countReducer, {count: 0})
  // NOTE: *możesz* potrzebować memoizacji tej wartości
  // Dowiedz się więcej: http://kcd.im/optimize-context
  const value = {state, dispatch}
  return <CountContext.Provider value={value}>{children}</CountContext.Provider>
}

export {CountProvider}
Enter fullscreen mode Exit fullscreen mode

Spokojnie, to wymyślony przykład, który celowo jest tak zrobiony, by pokazać, jak wyglądałaby bardziej rzeczywista sytuacja. Nie oznacza to, że za każdym razem będzie to tak skomplikowane! Jeśli to pasuje do Twojego przypadku możesz użyć useState. Ponadto, niektóre komponenty-dostawcy będą tak proste i krótkie, a inne DUŻO bardziej zawiłe, zawierające wiele hooków.

Customowy hook konsumenta

Większość interfejsów API, które widziałem, wygląda mniej więcej tak:

import * as React from 'react'
import {SomethingContext} from 'some-context-package'

function YourComponent() {
  const something = React.useContext(SomethingContext)
}
Enter fullscreen mode Exit fullscreen mode

Ale sądzę, że to zmarnowana szansa na zapewnienie lepszego user experience. Moim zdaniem powinno to być coś takiego:

import * as React from 'react'
import {useSomething} from 'some-context-package'

function YourComponent() {
  const something = useSomething()
}
Enter fullscreen mode Exit fullscreen mode

Ma to tą zaletę, że możesz zrobić kilka rzeczy, które teraz pokażę w praktyce:

import * as React from 'react'

const CountContext = React.createContext()

function countReducer(state, action) {
  switch (action.type) {
    case 'increment': {
      return {count: state.count + 1}
    }
    case 'decrement': {
      return {count: state.count - 1}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function CountProvider({children}) {
  const [state, dispatch] = React.useReducer(countReducer, {count: 0})
  // NOTE: *możesz* potrzebować memoizacji tej wartości
  // Dowiedz się więcej: http://kcd.im/optimize-context
  const value = {state, dispatch}
  return <CountContext.Provider value={value}>{children}</CountContext.Provider>
}

function useCount() {
  const context = React.useContext(CountContext)
  if (context === undefined) {
    throw new Error('useCount must be used within a CountProvider')
  }
  return context
}

export {CountProvider, useCount}
Enter fullscreen mode Exit fullscreen mode

Najpierw, hook useCount używa React.useContext, by otrzymać wartość kontekstu od najbliższego CountProvider. Jeśli nie ma takiej wartości, zwróci błąd zawierający pomocną wiadomość, wskazując, że hook nie został wywołany w komponencie funkcyjnym wyrenderowanym pod CountProvider. Jest to z pewnością błąd,
dlatego zwrócenie odpowiedniego komunikatu może być cenne. #FailFast

Customowy komponent konsumenta

Jeśli używasz hooków, pomiń tę sekcję. Jednak, jeśli musisz mieć wsparcie dla Reacta w wersji < 16.8.0 lub myślisz, że kontekst musi zostać użyty z komponentami klasowymi, oto jak możesz to zrobić korzystając z render-props:

function CountConsumer({children}) {
  return (
    <CountContext.Consumer>
      {context => {
        if (context === undefined) {
          throw new Error('CountConsumer must be used within a CountProvider')
        }
        return children(context)
      }}
    </CountContext.Consumer>
  )
}
Enter fullscreen mode Exit fullscreen mode

A oto jak można to wykorzystać w komponentach klasowych:

class CounterThing extends React.Component {
  render() {
    return (
      <CountConsumer>
        {({state, dispatch}) => (
          <div>
            <div>{state.count}</div>
            <button onClick={() => dispatch({type: 'decrement'})}>
              Decrement
            </button>
            <button onClick={() => dispatch({type: 'increment'})}>
              Increment
            </button>
          </div>
        )}
      </CountConsumer>
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

Używałem tego, zanim mieliśmy dostęp do hooków i działało sprawnie. Nie polecam jednak zawracania sobie tym głowy, jeśli możesz używać hooków. Hooki są znacznie lepsze.

TypeScript

Obiecałem, że pokażę, jak ustrzec się błędów związanych z pomijaniem wartości domyślnej, używając TypeScript-u. Zgadnij co! Robiąc, to o czym wspominałem, od razu unikasz problemu! Właściwie to wcale nie jest problem. Rzuć okiem na to:

import * as React from 'react'

type Action = {type: 'increment'} | {type: 'decrement'}
type Dispatch = (action: Action) => void
type State = {count: number}
type CountProviderProps = {children: React.ReactNode}

const CountStateContext = React.createContext<
  {state: State; dispatch: Dispatch} | undefined
>(undefined)

function countReducer(state: State, action: Action) {
  switch (action.type) {
    case 'increment': {
      return {count: state.count + 1}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function CountProvider({children}: CountProviderProps) {
  const [state, dispatch] = React.useReducer(countReducer, {count: 0})
  // NOTE: *możesz* potrzebować memoizacji tej wartości
  // Dowiedz się więcej: http://kcd.im/optimize-context
  const value = {state, dispatch}
  return (
    <CountStateContext.Provider value={value}>
      {children}
    </CountStateContext.Provider>
  )
}

function useCount() {
  const context = React.useContext(CountStateContext)
  if (context === undefined) {
    throw new Error('useCount must be used within a CountProvider')
  }
  return context
}

export {CountProvider, useCount}
Enter fullscreen mode Exit fullscreen mode

Dzięki temu każdy może używać hooka useCount bez konieczności sprawdzania wartości, ponieważ robimy to za niego!

Działający przykład - CodeSandbox

Co z literówkami w dispatch?

Jeśli chcesz używać kreatorów akcji, ok, ja jednak nigdy za tym nie przepadałem. Zawsze uważałem, że są one niepotrzebną abstrakcją. Używając TypeScript-u i mając dobrze otypowane akcje, najprawdopodobniej ich nie potrzebujesz. Dzięki temu uzyskujesz autouzupełnianie składni!

autouzupełnianie typu w dispatch

błąd w wypadku literówki

Polecam używać dispatch w ten sposób, dzięki temu jest on stabilny przez cały czas życia komponentu, który go utworzył, więc nie musisz martwić się o przekazywanie go jako zależności do useEffect.

Jeśli nie typujesz swojego kodu JavaScript (prawdopodobnie powinieneś to zmienić) wtedy zwracany błąd okaże się bezpiecznym rozwiązaniem. Przejdźmy do następnej sekcji, powinna ci pomóc.

Co z asynchronicznością?

Dobre pytanie. Co się stanie, jeśli będziesz potrzebował wykonywać asynchroniczne żądanie i zmienić kilka rzeczy podczas jego trwania? Jasne, że możesz to zrobić bezpośrednio w komponencie, ale ręczne ustawianie tego w każdej sytuacji byłoby dość denerwujące.

Sugeruję użycie funkcji pomocniczej, która przyjmuje jako argumenty dispatch oraz inne potrzebne dane i będzie odpowiedzialna za obsługę tego wszystkiego. Tutaj przykład z mojego kursu o zaawansowanych wzorcach w React:

async function updateUser(dispatch, user, updates) {
  dispatch({type: 'start update', updates})
  try {
    const updatedUser = await userClient.updateUser(user, updates)
    dispatch({type: 'finish update', updatedUser})
  } catch (error) {
    dispatch({type: 'fail update', error})
  }
}

export {UserProvider, useUser, updateUser}
Enter fullscreen mode Exit fullscreen mode

Następnie możesz tego użyć w ten sposób:

import {useUser, updateUser} from './user-context'

function UserSettings() {
  const [{user, status, error}, userDispatch] = useUser()

  function handleSubmit(event) {
    event.preventDefault()
    updateUser(userDispatch, user, formState)
  }

  // more code...
}
Enter fullscreen mode Exit fullscreen mode

Jestem zadowolony z tego wzorca, więc jeśli chciałbyś bym nauczył go w Twojej firmie daj mi znać (lub dopisz się do listy oczekujących na kolejne warsztaty)!

Podsumowanie

Tak wygląda finalny kod:

import * as React from 'react'

const CountContext = React.createContext()

function countReducer(state, action) {
  switch (action.type) {
    case 'increment': {
      return {count: state.count + 1}
    }
    case 'decrement': {
      return {count: state.count - 1}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function CountProvider({children}) {
  const [state, dispatch] = React.useReducer(countReducer, {count: 0})
  // NOTE: *możesz* potrzebować memoizacji tej wartości
  // Dowiedz się więcej: http://kcd.im/optimize-context
  const value = {state, dispatch}
  return <CountContext.Provider value={value}>{children}</CountContext.Provider>
}

function useCount() {
  const context = React.useContext(CountContext)
  if (context === undefined) {
    throw new Error('useCount must be used within a CountProvider')
  }
  return context
}

export {CountProvider, useCount}
Enter fullscreen mode Exit fullscreen mode

A tutaj działający CodeSandbox

Zauważ, że celowo nie eksportuję tutaj CountContext. Używam tylko jednego sposobu zarówno by ustawić, jak i wyciągnąć wartości. Dzięki temu mam pewność, że inni korzystają z tych wartości w bezpieczny sposób.

Mam nadzieję, że ten artykuł okazał się dla Ciebie pomocny! Pamiętaj:

  1. Nie powinieneś używać kontekstu, by rozwiązać każdy problem ze stanem.

  2. Kontekst nie musi być globalny dla całej aplikacji, a tylko dla jej części.

  3. Możesz (i prawdopodobnie powinieneś) mieć kilka, oddzielonych od siebie logicznie kontekstów.

Powodzenia!

Top comments (0)