Skip to content

State Management In Pure React

Posted on:September 7, 2021 at 01:32 PM

This post acts as notes for a course called “State Management in Pure React” on the FrontendMaster. The purpose of writing this post to cement the concepts.

The sample application is located here: https://codesandbox.io/s/grudge-list-forked-3gyxo?file=/src/Application.js Open the link and play around with it. The architecture of the application can be represented as following:

The application mimicks a todo list application. Instead of todo list we have a grudge list :). It contains a list of people who have wronged us. There is option to forgive them and add a new item to the list. I would recommend to playaround with it to get familiar.

The code for application.js is:

const Application = () => {
  const [grudges, setGrudges] = useState(initialState);

  const addGrudge = (grudge) => {
    grudge.id = id();
    grudge.forgiven = false;
    setGrudges([grudge, ...grudges]);
  };

  const toggleForgiveness = (id) => {
    setGrudges(
      grudges.map((grudge) => {
        if (grudge.id !== id) return grudge;
        return { ...grudge, forgiven: !grudge.forgiven };
      })
    );
  };

  return (
    <>
      <Log name="Application" />;
      <div className="Application">
        <NewGrudge onSubmit={addGrudge} />
        <Grudges grudges={grudges} onForgive={toggleForgiveness} />
      </div>
    </>
  );
};
export default Application;

We use useState for state management. We have two methods addGrudge and toggleForgiveness. addGrudge takes the current array of object and a new object and created a new array by merging the existing array of object with the new object and passes it to the setGrudges. toggleForgiveness receives id of the grudge object, and toggles the forgiven flag and passes the setGrudges a new array. The value for initialState state is an array of objects. You can open the initialState file for more details

[
  {
        "id": "14b35d08-9980-4b70-93e5-faa736168c35",
        "person": "Meta",
        "reason": "Parked too close to me in the parking lot",
        "forgiven": false
    },
    {
        "id": "20642da3-309a-4cda-a98f-7471735f50db",
        "person": "Ibbie",
        "reason": "Did not brew another pot of coffee after drinking the last cup",
        "forgiven": false
    },
]

The application.js has two child components.

  1. NewGrudge
  2. Grudges

NewGrudge

It is a component which has two html inputs and button. It takes a name and reason via the input and stores them locally using useState. The component takes a function name onSubmit as a prop and whenever user hits the submit button it calls that callback with person and reason as a parameter. Here is the function for the sake of completion

const NewGrudge = ({ onSubmit }) => {
  const [person, setPerson] = useState('');
  const [reason, setReason] = useState('');

  const handleChange = event => {
    event.preventDefault();
    onSubmit({ person, reason });
  };

  return (
    <form className="NewGrudge" onSubmit={handleChange}>
      <input
        className="NewGrudge-input"
        placeholder="Person"
        type="text"
        value={person}
        onChange={event => setPerson(event.target.value)}
      />
      <input
        className="NewGrudge-input"
        placeholder="Reason"
        type="text"
        value={reason}
        onChange={event => setReason(event.target.value)}
      />
      <input className="NewGrudge-submit button" type="submit" />
    </form>
  );
};

export default NewGrudge;

Grudges

This components takes two props, one an array of grudges called grudges and the second a callback called onForgive. The array of grudges has following structure:

[
  {
        "id": "14b35d08-9980-4b70-93e5-faa736168c35",
        "person": "Meta",
        "reason": "Parked too close to me in the parking lot",
        "forgiven": false
    },
    {
        "id": "20642da3-309a-4cda-a98f-7471735f50db",
        "person": "Ibbie",
        "reason": "Did not brew another pot of coffee after drinking the last cup",
        "forgiven": false
    },
]

This generated automatically you can view the code initialState.js

The Grudges in turn itself has a single child component Grudge. For each object in the grudges array, a Grudge component is instantiated. The code for Grudges component:

const Grudges = ({ grudges = [], onForgive }) => {
  return (
    <section className="Grudges">
      <h2>Grudges ({grudges.length})</h2>
      {grudges.map(grudge => (
        <Grudge key={grudge.id} grudge={grudge} onForgive={onForgive} />
      ))}
    </section>
  );
};

export default Grudges;

The Grudge component takes two props, one called grudge which represents an individual grudge item and second a callback onForgive. This component renders the person name, the grudge and a input with checkbox to show whether grudge is forgiven or not and whenever the grudge input is toggled the onForgive prop is called.

const Grudge = ({ grudge, onForgive }) => {
  const forgive = () => onForgive(grudge.id);

  return (
    <article className="Grudge">
      <h3>{grudge.person}</h3>
      <p>{grudge.reason}</p>
      <div className="Grudge-controls">
        <label className="Grudge-forgiven">
          <input type="checkbox" checked={grudge.forgiven} onChange={forgive} />{' '}
          Forgiven
        </label>
      </div>
    </article>
  );
};

export default Grudge;

Lets create a new component called Log like the name it will simply log to the console whenver it is rendered.

const Log = (name) => {
  console.log('rendering ', name);
  return null;
};

export default Log;

Lets import and use it in all the components. So lets type something in the inputs for adding a grudge. Notice name and reason is being type only the the NewGrudge component is being re-rendered, but as soon submit button is clicked the whole application is re-rendered including all the instances of the Grudge component. The reason this is happening is because the setState inside Application triggers a re-render, this re-renders starts at root and continues throughout the application.

Note that in setState we are not mutating the existing array or object instead we are creating a new array. This is because the setState function compares the previous and current values and only triggers re-render if they are different. If they are same it does not trigger the re-rendering process, so its important that if you are using non-primitive values then you should not mutate them. You can also use libraries like immer.js to avoid mutating objects.

Now lets implement the same functionality using the useReducer hook. lets create reducer first:

const GRUDGE_ADD = 'GRUDGE_ADD';
const GRUDGE_FORGIVE = 'GRUDGE_FORGIVE';

const reducer = (state = [], action) => {
  if (action.type === GRUDGE_ADD) {
    return [
      {
        id: id(),
        ...action.payload
      },
      ...state
    ];
  }

  if (action.type === GRUDGE_FORGIVE) {
    return state.map(grudge => {
      if (grudge.id === action.payload.id) {
        return { ...grudge, forgiven: !grudge.forgiven };
      }
      return grudge;
    });
  }

  return state;
};

Now we need to hook up this reducer with application. We can do this by using the react useReducer hook.

  const [grudges, dispatch] = useReducer(reducer, initialState);

We will use this hook to replace existing useState as we are handling the logic of adding grudge and forgiveness inside the reducer, we need to update the existing addGrudge and toggleForgiveness to dispatch an appropriate action to the reducer, and the reducer will handle the rest so lets code this:

const addGrudge = ({ person, reason }) => {
      dispatch({
        type: GRUDGE_ADD,
        payload: {
          person,
          reason,
          forgiven: false,
          id: id()
        }
      });
    }

  const toggleForgiveness = id => {
      dispatch({
        type: GRUDGE_FORGIVE,
        payload: { id }
      });
    }

Lets now try again adding a new grudge and toggle forgiveness, you will notice same logs in console as before :( no improvement!.

When user types something inside the input located in the NewGrudge component the state of the component is updated and a trigger is re-rendered inside the component.

Now when user clicks on the submit button, addGrudge is called which dispatches an action, this causes a re-render to start in root application and run all over the compononents hirerachy. Same thing happens when we toggle forgiveness inside the Grudge component.

So how can we avoid re-render. react documentation points towards a memo function. This function takes a component and memoizes its i.e it does not re-render the component unless its props changes. We can use this memo function inside our NewGrudge and Grudge component.

To use memo, just wrap the component with memo.

NewGrudge after memo:

import React, { useState } from 'react';
import Log from './Log';

const NewGrudge = React.memo(({ onSubmit }) => {
  const [person, setPerson] = useState('');
  const [reason, setReason] = useState('');

  const handleChange = (event) => {
    event.preventDefault();
    onSubmit({ person, reason });
  };

  return (
    <>
      <Log name="NewGrudge" />
      <form className="NewGrudge" onSubmit={handleChange}>
        <input
          className="NewGrudge-input"
          placeholder="Person"
          type="text"
          value={person}
          onChange={(event) => setPerson(event.target.value)}
        />
        <input
          className="NewGrudge-input"
          placeholder="Reason"
          type="text"
          value={reason}
          onChange={(event) => setReason(event.target.value)}
        />
        <input className="NewGrudge-submit button" type="submit" />
      </form>
    </>
  );
});

export default NewGrudge;

Similarly Grudge after memo:

import React from 'react';
import Log from './Log';

const Grudge = React.memo(({ grudge, onForgive }) => {
  const forgive = () => onForgive(grudge.id);

  return (
    <>
      <Log name={`Grudge ${grudge.person}`} />
      <article className="Grudge">
        <h3>{grudge.person}</h3>
        <p>{grudge.reason}</p>
        <div className="Grudge-controls">
          <label className="Grudge-forgiven">
            <input
              type="checkbox"
              checked={grudge.forgiven}
              onChange={forgive}
            />{' '}
            Forgiven
          </label>
        </div>
      </article>
    </>
  );
});

export default Grudge;

Even now if we check the console for logs, we will see there is still no improvement. This is because the memo function compares the props passed to previous invocation with props passed to current invocation and in our application the props change. Whenever the application is re-rendered the following code is executed:

const addGrudge = ({ person, reason }) => {
      dispatch({
        type: GRUDGE_ADD,
        payload: {
          person,
          reason,
          forgiven: false,
          id: id()
        }
      });
    }

  const toggleForgiveness = id => {
      dispatch({
        type: GRUDGE_FORGIVE,
        payload: { id }
      });
    }

In this piece of code we create a new function on each re-render and assign it to variables. To avoid this we can wrap the addGrudge and toggleForgiveness inside useCallback hook. This will make sure that a new function is not created on every re-render.

const addGrudge = useCallback(
    ({ person, reason }) => {
      dispatch({
        type: GRUDGE_ADD,
        payload: {
          person,
          reason,
          forgiven: false,
          id: id()
        }
      });
    },
    [dispatch]
  );

  const toggleForgiveness = useCallback(
    (id) => {
      dispatch({
        type: GRUDGE_FORGIVE,
        payload: { id }
      });
    },
    [dispatch]
  );

Now lets type something in the inputs and hit submit. You will notice that only application Grudges and Grudge component is re-rendered. The difference is that before in addition to these all the Grudge component would also re-rendered.

Now lets try to toggle the forgiveness checkbox. You will notice the instead of re-rendering all the Grudge components, only a single Grudge component will be re-rendered. Here is the link if you want to test: https://codesandbox.io/s/grudge-list-reducer-vmx9h?file=/src/Application.js

So why the need for all of this useReducer could not have we done the same thing with setState.

const [grudges, setGrudges] = useState(initialState);

  const setGrudges = grudge => {
    grudge.id = id();
    grudge.forgiven = false;
    setGrudges([grudge, ...grudges]);
  };

  const toggleForgiveness = id => {
    setGrudges(
      grudges.map(grudge => {
        if (grudge.id !== id) return grudge;
        return { ...grudge, forgiven: !grudge.forgiven };
      })
    );
  };

We could have used useCallback in the above code but since useCallback would dependecies on grudges and setGrudges we would have gotten a new referance to setGrudges and toggleForgiveness on each re-render.

useContext

In our application we prop drilling our way from application.js -> Grudges -> Grudge. We can avoid this prop drilling by using the reacts context api. First we need to extract the reducers and functions which dispatches the action into a separate file. In this new file will we create a context and pass these functions to the context. Later on we will wrap this context around NewGrudge and Grudges component.

contents of GrudgeContext.js

import React, { useReducer, createContext, useCallback } from 'react';
import initialState from './initialState';
import id from 'uuid/v4';

export const GrudgeContext = createContext();

const GRUDGE_ADD = 'GRUDGE_ADD';
const GRUDGE_FORGIVE = 'GRUDGE_FORGIVE';

const reducer = (state = [], action) => {
  if (action.type === GRUDGE_ADD) {
    return [
      {
        id: id(),
        ...action.payload
      },
      ...state
    ];
  }

  if (action.type === GRUDGE_FORGIVE) {
    return state.map(grudge => {
      if (grudge.id === action.payload.id) {
        return { ...grudge, forgiven: !grudge.forgiven };
      }
      return grudge;
    });
  }

  return state;
};

export const GrudgeProvider = ({ children }) => {
  const [grudges, dispatch] = useReducer(reducer, initialState);

  const addGrudge = useCallback(
    ({ person, reason }) => {
      dispatch({
        type: GRUDGE_ADD,
        payload: {
          person,
          reason
        }
      });
    },
    [dispatch]
  );

  const toggleForgiveness = useCallback(
    id => {
      dispatch({
        type: GRUDGE_FORGIVE,
        payload: {
          id
        }
      });
    },
    [dispatch]
  );

  return (
    <GrudgeContext.Provider value={{ grudges, addGrudge, toggleForgiveness }}>
      {children}
    </GrudgeContext.Provider>
  );
};

contents of application.js

import React from 'react';

import Grudges from './Grudges';
import NewGrudge from './NewGrudge';

const Application = () => {
  return (
    <div className="Application">
      <NewGrudge />
      <Grudges />
    </div>
  );
};

export default Application;

contents of index.js

ReactDOM.render(
  <GrudgeProvider>
    <Application />
  </GrudgeProvider>,
  rootElement
);

Now the next step is to use the functions provided by context instead of using the one passed by props. So Grudges component after the the modification will look like this:

import React from 'react';
import Grudge from './Grudge';
import { GrudgeContext } from './GrudgeContext';

const Grudges = () => {
  const { grudges } = React.useContext(GrudgeContext);

  return (
    <section className="Grudges">
      <h2>Grudges ({grudges.length})</h2>
      {grudges.map(grudge => (
        <Grudge key={grudge.id} grudge={grudge} />
      ))}
    </section>
  );
};

export default Grudges;

Note that we are using useContext to get access to the GrudgeContext. We have also removed memo because it is not required anymore.

Now lets take a look at Grudge component:

import React from 'react';
import { GrudgeContext } from './GrudgeContext';

const Grudge = ({ grudge }) => {
  const { toggleForgiveness } = React.useContext(GrudgeContext);

  return (
    <article className="Grudge">
      <h3>{grudge.person}</h3>
      <p>{grudge.reason}</p>
      <div className="Grudge-controls">
        <label className="Grudge-forgiven">
          <input
            type="checkbox"
            checked={grudge.forgiven}
            onChange={() => toggleForgiveness(grudge.id)}
          />{' '}
          Forgiven
        </label>
      </div>
    </article>
  );
};

export default Grudge;

Similarly to the Grudges component we are reading value from useContext instead of passing down prop.

Changes for NewGrudge

const NewGrudge = () => {
  const [person, setPerson] = React.useState('');
  const [reason, setReason] = React.useState('');
  const { addGrudge } = React.useContext(GrudgeContext);

  const handleSubmit = event => {
    event.preventDefault();

    addGrudge({
      person,
      reason
    });
  };

  return (
    // …
  );
};

export default NewGrudge;

This is all that required to move our application to use context. The advantage of this approach is that we do not have to do prop drilling any more but we the disadvantage is that we lose the performance gains. If you open console you will notice that the rendering behvaiour is same as that of before memo.

Creating a custom hook for fetching data.

const useFetch = url => {
  const [response, setResponse] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    setError(null);
    setResponse(null);

    fetch(url)
      .then(response => response.json())
      .then(response => {
        setResponse(response);
        setLoading(false);
      })
      .catch(error => {
        setError(error);
        setLoading(false);
      });
  }, [url]);

  return [response, loading, error];
};

Lets see how can we handle with async functions:

useEffect(() => {
    console.log('Fetching');

    setLoading(true);
    setError(null);
    setResponse(null);

    const get = async () => {
      try {
        const response = await fetch(url);
        const data = await response.json();
        setResponse(formatData(data));
      } catch (error) {
        setError(error);
      } finally {
        setLoading(false);
      }
    };

    get();
  }, [url, formatData]);

  return [response, loading, error];
};

We are using setState inside our we can refactor it to use useReducer

const fetchReducer = (state, action) => {
  if (action.type === 'FETCHING') {
    return {
      result: null,
      loading: true,
      error: null,
    };
  }

  if (action.type === 'RESPONSE_COMPLETE') {
    return {
      result: action.payload.result,
      loading: false,
      error: null,
    };
  }

  if (action.type === 'ERROR') {
    return {
      result: null,
      loading: false,
      error: action.payload.error,
    };
  }

  return state;
};

and we can use the reducer hook like this:

const useFetch = (url, dependencies = [], formatResponse = () => {}) => {
  const [state, dispatch] = useReducer(fetchReducer, {
    result: null,
    loading: true,
    error: null,
  });

  useEffect(() => {
    dispatch({ type: 'FETCHING' });
    fetch(url)
      .then(response => response.json())
      .then(response => {
        dispatch({
          type: 'RESPONSE_COMPLETE',
          payload: { result: formatResponse(response) },
        });
      })
      .catch(error => {
        dispatch({ type: 'ERROR', payload: { error } });
      });
  }, [url, formatResponse]);

  const { result, loading, error } = state;

  return [result, loading, error];
};