Build your own React – Episode V

Special Effects Time: useEffect

Welcome back to the Babbel Bytes series on “Build your own (simple) React from scratch!”. We are continuing to dive into the intricacies of React to build our own version based on a workshop originally created for React Day Berlin 2022.

Haven’t read the earlier parts? Check out the previous article or the first article in the series.

So far we have implemented the ability to render JSX to the DOM, familiarized ourselves with the Virtual DOM, made our components stateful through the creation of the useState hook, and lastly looked at diffing and updating the DOM efficiently on state changes.

Now it’s time to implement another of React’s most important hooks, useEffect.

What’s an effect?

Hooks have changed the game for functional components as they allow them to store state (via the useState hook which we implemented in Ep. 3) and perform side effects after rendering, or on property (prop) changes.

Remember, hooks are functions that allow you to “hook into” React state and lifecycle features from functional components (not class components). 

Side-effects are the logic that happens independently from the rendering of a component, e.g. data fetching and subscriptions, that can affect other components. The hook responsible for encapsulating this logic is useEffect, which, if you’re familiar with Class components, serves the same purpose as componentDidMount, componentDidUpdate, and componentWillUnmount but all bundled into one api.

The anatomy of useEffect

The useEffect hook is a function that takes two parameters:

  1. A function – that contains your “effect”.
  2. A dependency array – an array containing all the variables that the effect depends on.

The useEffect statement must be declared inside a component (or a custom hook) so it can access its props and state. That also means it is called on every render of the component – including the first. Your effect function is therefore called after the first render and on all subsequent renders that meet the conditions of your dependency array, comparing each item in the array shallowly. If you leave your dependency array empty ([]), your effect function will be called after every render.

Let’s look at the code snippet below, which has a count state, which is displayed to the user and a useEffect statement implemented.

Our “effect” here is to store the count in the browser’s local storage using the localStorage API, which is available on the `window’. Setting data in local storage is handy as it allows data to persist even after a user closes the browser.

import { useEffect, useState } from 'react';

const Counter = () => {
    const [count, setCount] = useState(0)

    useEffect(() => {
        localStorage.setItem(“count”, JSON.stringify(count));
    }, [count]);

    return (
        <div>
            {count}
        </div>
    );
};

If we were to call the localStorage.setItem outside of the useEffect, it would be triggered on every render, which is inefficient and not what we want.

Now remember, our effect will be called on the first render and whenever a variable in our dependency array changes, which in the example above is [count]. Therefore, whenever the count variable updates, our effect will be called and we will update our local storage “count” with the new count in our component’s state. This is great as it allows us to only set the local storage when it needs to be updated, i.e. when count changes, and not on every render. useEffect also offers an optional way to “clean up” after itself to avoid any unwanted behavior, like memory leaks. This is achieved by returning a function in the effect function. Take a look at the example below:

useEffect(() => {
  const timer = setTimeout(() => {
    console.log('This will run after 1 second!')
  }, 1000);
  return () => clearTimeout(timer);
}, []);

In our effect function we are setting a timeout that will be called after a second.

We are then returning a function that clears that timeout (to prevent any unwanted side effects). If you are quite familiar with React hooks, then you are probably aware that this cleanup function is called right before the component unmounts. But what is often unknown is that this function also runs before the next scheduled effect. In other words, when a variable in our dependency array changes, the cleanup function will run before running the next effect function with the new variable value.

This part is very important to keep in mind as we move on to implementing the useEffect hook ourselves.

Here’s a diagram to highlight how the useEffect hook is connected to the lifecycle of a component.

Let’s get started

So how on earth are we going to build this ourselves, we hear you cry? Luckily we have the workshop guidelines and accompanying ToDo app to help and we’ve made some really good progress so far.

If you remember from Episode 3, a hook is bound to an instance of the Component it is defined in and identified by the order of the hook calls within that component, e.g., the first hook defined has an index of 0.

In order for our ToDo app to be useful, we need our added items to persist and not vanish every time the page reloads. Therefore, we are going to utilize local storage to set and retrieve our todo items.

This is where our useEffect hook comes in. We have created a custom hook in our app, useLocalStorage, which retrieves and exposes the value stored in localStorage. It also exposes a setValue method to be called whenever a new item is added, which updates the local state value. In order for this new value to not just be set locally but also to local storage, we rely on the useEffect hook.

The useEffect has value as a dependency, so when an item is added, the effect function is triggered, which then sets or updates the value of the item in local storage. Note, we are storing all added items under one local storage field by stringifying the array of items. We then parse this string back into an array when retrieving the value from local storage.

export const useLocalStorage = (key, defaultValue) => {
  const [value, setValue] = useState(() =>
    getStorageValue({ key, defaultValue }),
  );

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
};

> If you are following along, it is now time to go to the branch git checkout chapter-4/step-1

Before we jump into the challenge, let’s dissect the boilerplate code that we have already provided.

Remember, useEffect hooks are declared in the functional component, so they will be called on every render, which is necessary for it to react to the latest state or prop changes of the component it is called from. We want our effect function, however, to be called after the DOM is rendered.

Luckily we already have the startRenderSubscription function that subscribes to DOM changes and is called on every render, keeping track of the previous and current VDOM.

Let’s take a look at the updated startRenderSubscription function and detail the changes that were necessary.

// this function will call the updateCallback on every state change
// so the DOM is re-rendered
export const startRenderSubscription = (element, updateCallback) => {
  let vdom = {
    previous: {},
    current: {},
  }; // 1

  let afterUpdate; // 2

  const registerOnUpdatedCallback = callback => {
    afterUpdate = callback;
  }; // 3

  const update = hooks => {
    const renderableVDOM = rootRender(element, hooks, vdom);

    // We calculate the diff between the renderableVDOM and the previous VDOM here
    const diff = getRenderableVDOMDiff(renderableVDOM, vdom);

    vdom.previous = vdom.current;
    vdom.current = [];

    updateCallback(renderableVDOM, diff);

    afterUpdate();
  }; // 4

  const hooks = createHooks(update, registerOnUpdatedCallback); // 5
  update(hooks); // 6
};

So what is actually happening here? From top down it…

  1. Keeps track of the virtual DOM over time by keeping a previous and current version
  2. Creates an afterUpdate function (as we need to call our useEffect hooks after the DOM has updated) 🆕
  3. Creates a registerOnUpdatedCallback function that sets afterUpdate and is called when the hooks are created and updated, so afterUpdate references their current state. 🆕
  4. Creates an update function that ultimately updates the DOM and calls the afterUpdate function ♻️
  5. Create our hooks structure using an update createHooks helper ♻️
  6. Calls the update function with the hooks that are created for the first render

The key things to keep in mind are that we create our hooks map by passing through the update function and now also the registerOnUpdatedCallback function, so that our hooks can create callbacks to be run after renders.

Now remember our hooks map is an object where the key is an element’s VDOM pointer (a unique ID as it’s the element’s position within the tree), and the value is the state of its hooks.

interface HooksMap {
 [VDOMPointer]: { state: [STATE], effect: [EFFECT] }
};

Let’s dig into the createHooks function to learn how our hooks are created and tracked.

export const createHooks = (onUpdate, registerOnUpdatedCallback) => {
  // hooksMap[[0,0,0]] is the hooks for the component with VDOMPointer [0, 0, 0]
  // Each value (hook) e.g. hooksMap[[0,0,0]] has the following structure { state: [], effect: []}
  const hooksMap = {}; // 1
  const hooks = { current: null }; // 2
  const boundOnUpdate = () => onUpdate(hooks.current); // 3
  const makeUseState = createMakeUseState(boundOnUpdate, hooksMap); // 4
  const makeUseEffect = createMakeUseEffect(
    registerOnUpdatedCallback,
    hooksMap,
  ); // 5
  const registerHooks = makeRegisterHooks(hooksMap, makeUseState); // 6
  hooks.current = { registerHooks }; // 7
  return hooks.current;
};

Let’s once again break down each part:

  1. We create the initial hooksMap to keep track of the hooks for each component
  2. We create a variable similar to a React ref to keep our hooks object (this is useful because we need to pass the hooks to onUpdate, but we need to use onUpdate in order to create our hooks object). Using a ref like structure, allows us to already use the variable but initialize it later.
  3. We bind the current hooks to the onUpdate function to simplify the calls to it.
  4. We create makeUseState by providing its dependencies: the boundOnUpdate function and the hooks map.
  5. We create makeUseEffect by providing its dependencies: the registerOnUpdatedCallback function and the hooks map. Remember we want to run our useEffects’ effects after the component has been rendered. 
  6. We create registerHooks which returns a function that allows us to bind hooks to a specific component’s instance.
  7. We replace the current value of our hooks by an object containing our registerHooks function
  8. We return the hooks object.

And remember createHooks is called by startRenderSubscription, which uses it as the only argument of its update function. The update function ultimately passes it to the render function. This results in hooks.registerHooks being called right before the component is rendered, so the component instance references the most up to date hooks state.

For the eagle-eyed readers, you may have noticed that we are declaring makeUseEffects but not using it anywhere… something to keep in mind as we approach the first coding challenge. But before you jump into that, let’s take a look at the function that creates it, where most of the work will be.

Similarly to createMakeUseState, it’s a higher-order function (a function that returns a function), that has two arguments: registerOnUpdatedCallback and hooksMap. It’s a bit like a lasagna, this function has three layers of functions:

  1. The first is the logic that’s actually run when it’s called, which happens on first render and is responsible for setting up and registering our onUpdatedCallback – the function that will call all of our effects, and returns…
  2. A function that creates our useEffect function for each component instance, i.e, it will be run for each element in our VDOM. Hence, why it takes the VDOM pointer and isFirstRender arguments. It also returns a function…
  3. … that is the useEffect function that will be called in the component itself.

Remember registerOnUpdatedCallback is the function that will trigger all of the applications useEffect functions after a component updates.

const createMakeUseEffect = (registerOnUpdatedCallback, hooksMap) => {
  // Here we keep a combined callback reference that is the
  // function we want to call after a render cycle
  const combinedCallbackRef = { current: () => {} }; // 1
  // After every update, we will call that callback
  // and then reset it so the effects are ran just once
  registerOnUpdatedCallback(() => {
    combinedCallbackRef.current();
    combinedCallbackRef.current = () => {};
  }); // 2
  // This is a utility function that allows you to set a
  // callback to be ran after the dom has been updated
  const registerEffectForAfterDOMUpdate = callback => {
    const { current } = combinedCallbackRef;
    // it updates the combined callback reference
    // to call itself first (so it calls all the previously registered callbacks)
    // and then calls the newly registered one
    combinedCallbackRef.current = () => {
      current();
      callback();
    };
  }; // 3

  return (VDOMPointer, isFirstRender) => { // 4
    // DON'T FORGET FOR AFTER THE EFFECT RUNNING ON EVERY UPDATE
    // How similar is useEffect to useState in the way they work?
    const currentHook = hooksMap[VDOMPointer];

    // At this point within this function, we are creating the function
    // that answers to `useEffect` calls within our components.
    // The first argument is the effect callback that the developer wants to run after the render.
    // The second argument is the dependencies array which should be used to determine whether the effect
    // should run or not in re-renders.
    return (effectCallback, dependencies) => { // 5
      // DON'T FORGET FOR AFTER THE EFFECT RUNNING ON EVERY UPDATE
      // With this code, the effect will be run on every render update
      // how can we make sure it only runs when the dependencies were updated?
      // ps: we created for you a areDependenciesEqual function, so you can compare dependencies with
      // areDependenciesEqual(previousDependencies, currentDependencies)
      registerEffectForNextRender(() => {
        effectCallback();
      });
    };
  };
};
  1. We define the callback of effects again using a ref like structure, which will be called after every render cycle.
  2. Then we call registerOnUpdatedCallback with a function, which will then be called after every update. The function itself calls our top-level hooks function that runs all the effects. We then immediately reset that function to ensure it is only called once.
  3. We then create a utility function registerEffectForAfterDOMUpdate, which takes a callback (which will be a single effect) and allows this effect to be triggered after the DOM update. A simple way to think of this function is that it takes an effect, sets a new combinedCallback (the function that runs all the effects) by calling the current combinedCallbackRef and the new effect. It’s like adding the effect on top of the pile of existing effects. So for every effect, we create a new function that will call the existing effects AND the new effect.

Now we are getting into the function that is returned by createMakeUseEffect (Layer 2).

  1. The function returned is responsible for setting up the useEffect for each component. Therefore, it takes the VDOMPointer and isFirstRender as arguments. It returns the useEffect function that will be used within React components. Note, you’ll need to finish this function in the first step of this episode. 
  2. The returned useEffect function (Layer 3) as we’ve learnt expects the effect callback and an array of dependencies. Again, it is unfinished so we’ll need your help here. But what it does contain is a commented out helper function, areDependenciesEqual, that compares the current and previous dependencies – remember our effect callback should only run when an element in the dependency array changes. It also contains the triggering of the registerEffectForNextRender function with the effect callback. But this is currently being called on every update… is this what we want?

In the first coding challenge, the starting point will be in the makeRegisterHooks function, where the makeUseEffects function is missing.  We’d then like you to have a go at updating the createMakeUseEffect function so it works as expected – the effect callback is triggered after render but only when the conditions of the dependency array are met. Remember that each React component can have multiple effects so you are going to need to allow this by storing the effects index (in the 2nd layer).

// Higher-Order Function that replaces hooks so they know which component
// they relate to (at the specified VDOMPointer)
const makeRegisterHooks =
  (hooksMap, makeUseState) => (VDOMPointer, isFirstRender) => {
    if (isFirstRender) {
      hooksMap[VDOMPointer] = {};
    }
    const useState = makeUseState(VDOMPointer, isFirstRender);
    globalHooksReplacer.useState = useState;
    // START HERE
    // We will need to register useEffect so it works for our components
    // Maybe you can take inspiration from the way we developed useState?
  };

Stop scrolling, give it a whirl. The demo app can be your guide – you’ve got it working if you add a todo item and that item still persists after making a hard refresh of the page.

> If following along, it is now time to go to the branch git checkout chapter-4/step-2

Let’s take a look at the solution, starting with the makeRegisterHooks function.

// Higher-Order Function that replaces hooks so they know which component
// they relate to (at the specified VDOMPointer)
const makeRegisterHooks =
  (hooksMap, makeUseState, makeUseEffect) => (VDOMPointer, isFirstRender) => {
    if (isFirstRender) {
      hooksMap[VDOMPointer] = {};
    }
    const useState = makeUseState(VDOMPointer, isFirstRender);
    globalHooksReplacer.useState = useState;

    const useEffect = makeUseEffect(VDOMPointer, isFirstRender);
    globalHooksReplacer.useEffect = useEffect;
  };

Just like with useState, we create useEffect by calling the makeUseEffect function with the VDOMPointer, so it can create the hook for each specific component, and isFirstRender so we can initialize the array of effects.

We then set this useEffect function on the globalHooksReplacer, so that it’s linked correctly to a specific component instance while being available as a named import from our library.

To ensure that the makeUseEffect function referenced above is the one we want, we need to pass it to makeRegisterHooks in the createHooks function, like below.

  const registerHooks = makeRegisterHooks(
    hooksMap,
    makeUseState,
    makeUseEffect,
  );

Now that we have everything hooked up as we want it we can move onto the trickier part and the missing logic of the createMakeUseEffect function.

  return (VDOMPointer, isFirstRender) => {
    const effectIndexRef = { current: 0 }; // 1
    const currentHook = hooksMap[VDOMPointer];
    if (isFirstRender) {
      currentHook.effect = [];
    }
    return (effectCallback, dependencies) => {
      const effectIndex = effectIndexRef.current; // 2
      const previousEffect = currentHook.effect[effectIndex] || {}; // 3
      effectIndexRef.current += 1; // 4
      if (areDependenciesEqual(previousEffect.dependencies, dependencies)) {
        return;
      } // 5
      currentHook.effect[effectIndex] = {
        dependencies: [...dependencies],
      }; // 6
      registerEffectForAfterDOMUpdate(() => {
        // Here we will save the cleanUp to be called later in our effect structure
        // but where does the cleanUp come from?
        // currentHook.effect[effectIndex].cleanUp = ...

        effectCallback();
      });
    };
  };

Let’s go through the additions step-by-step:

  1. We store a reference to the effect index because each React component can have multiple effects i.e. call the useEffect function a number of times. 

We are now inside the useEffect function that gets called from each React component

  1. We set the effectIndex to the latest value currently stored. 
  2. We then initialize and retrieve the previous effect by using the index as a reference against the effects array of the current hook. If there is no previous effect we just set it to an empty object.
  3. Then we bump the effectIndexRef value by 1 so that the component’s next useEffect call has the correct index. Remember Javascript and React code is run sequentially. So each useEffect declared within a component, will be run in the same order each time and therefore we can rely on this index to differentiate them. This is also maintained by the rules of hooks: hooks should not be called conditionally!
  4. After we compare the dependencies with the previous dependencies using the helper function areDependenciesEqual. As we only want to call our effect on a dependency change, if the dependencies are equal we return out of the function to prevent the effect being called.
  5. If the dependencies have changed, we need to update them on our store of effects using the effect index as a reference. This ensures that the correct dependencies are used by the specific effect when it is next called.
  6. Lastly, we call the utility function registerEffectForAfterDOMUpdate with a function that runs the effectCallback. This essentially adds the effect trigger to the list of all effects that are supposed to be called after the next render. 

We now have a ToDo app that behaves as we’d expect – adding items to a list and those items persisting even on page reloads, so they aren’t lost in the ether! So we did the thing? Not quite…but we are close! There’s one small but important feature of the useEffect that we haven’t implemented yet – cleaning up after ourselves.

Quick recap before diving into the last challenge of this workshop. Remember the useEffect effect callback can return an optional function that is run when a component unmounts AND before the next effect is run.

useEffect(() => {
        effect
        return () => {
            cleanup
        }
}, [dependency])

Inside this function we can add any necessary cleanup logic to prevent memory leaks and undesired behavior.

So let’s have a go at implementing this feature ourselves. Some hints before you get started…

  1. The composition of the useEffect function (3rd layer of the lasagna) is the only place where you’ll need to add code.
  2. The cleanup function will need to be stored and triggered before the next effect is called.
  3. How do we access the cleanup function?

Have a go and once you think you’ve cracked it or start to lose your marbles, head over to the final branch to see the solution and our complete React implementation. 

> If following along, it is now time to go to the branch git checkout final

Here’s the final piece of the jigsaw…

    return (effectCallback, dependencies) => {
      const effectIndex = effectIndexRef.current;
      const previousEffect = currentHook.effect[effectIndex] || {};

      const { cleanUp = () => {} } = previousEffect; // 1
      effectIndexRef.current += 1;
      if (areDependenciesEqual(previousEffect.dependencies, dependencies)) {
        return;
      }

      cleanUp(); // 2
      currentHook.effect[effectIndex] = {
        dependencies: [...dependencies],
        cleanUp: () => {}, // 3
      };

      registerEffectForAfterDOMUpdate(() => {
        currentHook.effect[effectIndex].cleanUp =
          effectCallback() || (() => {}); // 4
      });
    };

Our last step-by-step breakdown (Keep those tears in):

  1. First thing is to access the cleanUp function stored on the previous effect. If one doesn’t exist we set the value to an empty function.
  2.  We then call cleanUp before registering the next effect.
  3. To prevent the cleanUp being called more than twice we immediately reset it on the previous effect’s store to be an empty function.
  4. Lastly we store the next cleanUp function by setting it to the value (the function) returned by the effectCallback. We once again set it to an empty function if the return value is falsey.

And that’s that! 5 (long) articles later, a heap of complex coding challenges and concepts and you’ve done it, you’ve built your own version of React. Take a step back and marvel at your hard work that allows your app to render and update components efficiently, enables those components to store local state and handle side effects.

R.I.P React? Maybe not yet, as there’s still quite a lot of work to be done to replicate all of React’s offerings and capabilities, but you’ve managed to implement the key elements which is no small feat! Hopefully you have also unlocked a deeper understanding of how React operates behind your apps, in addition to some useful functional programming techniques. 

To further boost your knowledge and tie everything together nicely, we have one more blog post for you, where we will provide a comparison of our implementation with the actual React, highlighting the differences in approach.

Share: