W dzisiejszym artykule chciałbym zwrócić uwagę na problem ponownego renderowania komponentów, które korzystają z Context API i sposobie na uniknięcie rerenderingu m.in. poprzez mechanizm memoizacji, czy też podział „kontekstu”.

W poprzednim artykule dotyczącym Context API przedstawiłem proste użycie Context’u oraz wskazałem, że Context API może być odpowiedzią na pojawiające się props drilling. Link do poprzedniego artykułu jest dostępny tutaj (Context API – użycie, prop drilling, cz. 1).

Niedogodnością Context API jest jednak to, że komponenty, które korzystają z Contextu, zostaną przerenderowane, nawet jeżeli wartość konsumowana (wartość, z której korzysta komponent) nie uległa zmianie, ale do danego context’u należy…

Co to oznacza w praktyce?

Wyobraźmy sobie Context API, które przechowuje dwie wartości – imię i nazwisko. Teraz budujemy dwa komponenty, jeden wyświetlający imię, drugi nazwisko. Nawet jeżeli zmienimy TYLKO imię, komponent który wyświetla nazwisko, również zostanie wyrenderowany ponownie, korzystając z tego samego ContextAPI.

W poniższym bardzo prostym przykładzie pozwolę sobie odzwierciedlić ten przypadek. Mamy jeden Context API oraz dwa komponenty. Jeden odpowiedzialny za wyświetlenie imienia oraz drugi, odpowiedzialny za wyświetlenie nazwiska. Mamy też funkcję, która wywoływana w komponencie <Name /> zmienia zawartość stanu i przekazuje zmienione wartości o Context API. Został też dodany Math.random(), aby w bardzo prosty sposób sprawdzić czy wywołuje się ponowny render, bowiem wtedy wartość ta ulegnie zmianie.

Context API - przykład rerender
const ExampleContext = React.createContext();

const Name = () => {
    const { name, setName } = React.useContext(ExampleContext);
    return (
        <div>
            <h2>Name: {name}</h2>
            <button onClick={() => setName(Math.random())}>
                Change name
            </button><br />
            Random: {Math.random()}
        </div>
    )
}

const Surname = () => {
    const { surname } = React.useContext(ExampleContext);
    return (
        <div>
            <h2>Surname: {surname} </h2>
            Random: {Math.random()}
        </div>
    )
}

function App() {
    const [name, setName] = React.useState('Name');
    const [surname] = React.useState('Surname');
    const value = {
        name,
        setName,
        surname
    };

    return (
        <ExampleContext.Provider value={value}>
            <Name/>
            <Surname/>
        </ExampleContext.Provider>
    )
}

export default App;

Taki podział kodu sprawi, że za każdym razem kiedy zostanie kliknięty przycisk do zmiany imienia, przerenderuje się również komponent <Surname />.

Uwaga!
Pamiętajmy, że przerenderowanie <Surname /> nie wynika tylko z użycia Context API, ale również z tego, że komponent rodzica, czyli komponent <App/> został przerenderowany.

Jednym z rozwiązań, które pomoże uniknąć ponownego przerenderowania, jest użycie React.memo(). Użycie React.memo() wygląda bardzo podobnie jak użycie useCallback() (o useCallback() pisałem o tym w innym artykule).

Poniżej fragment kodu komponentu <Surname /> wraz z użyciem React.memo(), gdzie jako drugi parametr możemy zdefiniować tablicę z wartościami. Zmiana podanych w parametrze wartości pozwoli na ponowne renderowanie. Jeżeli wartość nie ulegnie zmianie, komponent nie wyrenderuje sie ponownie, co w naszym przypadku w dobry sposób zobrazuje Math.random() i wyrenderowana stara wartość.

Jeżeli zmodyfikujemy kod zmieniając na fragment kodu naszą prostą aplikację, komponent <Surname /> nie będzie się renderował ponownie, ponieważ wartość surname pozostaje ciągle taka sama.

const Surname = () => {
    const { surname } = React.useContext(ExampleContext);
    return React.useMemo(() => {
        return (
            <div>
                <h2>Surname: {surname} </h2>
                Random: {Math.random()}
            </div>
        )
    }, [surname]);
}

Co jeszcze możemy zrobić, aby uniknąć zbyt wielu renderów?

W przypadku Context API, możemy zastanowić się, nad poprawnym podziałem komponentów i użyciem więcej niż jednego Context API dla drzewa komponentów. Nasz przykład z imieniem i nazwiskiem jest bardzo prosty i dla małej ilości komponentów oczywiście nie ma sensu dzielić tego na wiele „contextów”.
Jeżeli w naszym przykładzie chcielibyśmy jednak na siłę podzielić Context API, możemy przygotować dwa oddzielne Context API, jeden odpowiedzialny za imię, drugi za nazwisko, wynieść je ponad komponent <App /> (aby uniknąć przerenderowywania rodzica, a co za tym idzie komponentu<Surname /> po zmianie state imienia). Chociaż tak jak wspomniałem, przy mniejszym skomplikowaniu, nie ma to większego sensu.

Podział Context API
Podział Context API

Co do dzielenia Context API na mniejsze elementy. W mojej opinii, wszystko zależy od problemu, od tego jakie dane przechowujemy, czy są one ze sobą jakoś logicznie powiązane, jak często dane się zmieniają, jak mocno zagnieżdżone komponenty posiadamy, jak złożona jest logika komponentów oraz ile tych komponentów jest.

W internecie bardzo często pojawia się pytanie, czy Context API, może zastąpić Reduxa, MobX itd?

Sam osobiście nie podejmowałem takiej próby, natomiast w związku z występującym rerenderingiem, byłbym bardzo ostrożny, przy użyciu jednego Context API w większych aplikacjach, jako globalnego state management’u i z podobną oceną można się spotkać czytając inne artykuły na ten temat.

Spotkałem się jednak z przykładami, w których globalny Context API był używany jako element do zarządzania layoutem/wyglądem w aplikacji, w związku z tym że komponenty będą uzależniały swój tryb wyświetlania od jakiejś wartości, a jego zmiana będzie wymagała ponownego renderowania większości widocznych komponentów.

Przyjrzyjmy się jeszcze kilku różnicom przy wykorzystaniu Redux oraz Context API.

Korzystając z Redux, dzięki użyciu connect() oraz mapStateToProps() i mapDispatchToProps() jesteśmy w stanie zapewnić reużywalność komponentu. W poniższym przykładzie this.props.todoList może zawierać różne dane, które będą przekazane do tego samego <TodoList />

class TodoList extends Component {
  render() {
    return (
      <div>
        <p>{this.props.todoList.length}</p>
      </div>
    );
  }
};

function mapStateToProps(state) {
  return { todoList: state.todos }
}

// Inne dane - ten sam komponent...
// function mapStateToProps(state) {
//     return { todoList: state.otherTodoList }
// }

export default connect(mapStateToProps)(TodoList)

Podczas wykorzystania Context API moglibyśmy stworzyć komponent, który przyjmuje dane oraz komponent, który odbiera dane z Context API i przekazuje je niżej…

function Component() {
        // pierwszy kontekst
	const { todoList } = React.useContext(TodoListContext);
        // inny kontekst...
	// const { todoList } = React.useContext(MagicListTodoContext);

 
	return (<ReusableComponent todo={todoList} />);
}
export default Component;

Bez takiego podejścia, nasz komponent byłby uzależniony od danego Context API, przez co reużywalność komponentu byłaby ograniczona. Obrazuje to poniższy przykład.

function Component() {
	const { todoList } = React.useContext(TodoListContext);

	return (
                <div>
                     Uzaleznienie komponentu od TodoListContext
                     {todoList.map(() => { (...) })}
                </div>
        );
}
export default Component;

Dodatkowo, użycie Reduxa daje nam możliwość korzystania z takich rozwiązań jak redux-thunk lub redux-saga.

Źródła i dodatkowe materiały:
React Context API – oficjalna dokumentacja
Redux

Obraz: https://unsplash.com/photos/46juD4zY1XA
Diagram: draw.io