DEV Community

Andrii Vyhovanok
Andrii Vyhovanok

Posted on

A deep dive into React Hooks (useState, useEffect)

Introduction✋😃

Greetings, everyone! Today, we will explore how basic hooks work in React.
 To fully understand their logic, it is essential to have a basic understanding of React's internal mechanism - Fiber.

What is Fiber?🧠

Fiber is an object that represents a node in the virtual DOM and serves as the foundation of modern React. It allows React to perform flexible, asynchronous, interruptible, and prioritized rendering.

Each component, for example:

<ComponentButton />

is a separate Fiber node, which contains information about state, props, and links to other components.
The main role of Fiber is to efficiently manage updates and rendering priorities.
(Fiber is created during React initialization via ReactDOM.createRoot())

What is useState📄

According to React documentation, useState is a React Hook that lets you add a state variable to your component.

How does useState work under the hood?⚙️

Let's examine the basic state initialization:

const [status, setStatus] = useState("useState under the hood: On Mount");

Internally, React executes the following code:

useState: function (initialState) {
   initialState = mountStateImpl(initialState);
   var queue = initialState.queue,
   dispatch = dispatchSetState.bind(null, currentlyRenderingFiber$1, queue);
   queue.dispatch = dispatch;
   return [initialState.memoizedState, dispatch];
}
Enter fullscreen mode Exit fullscreen mode

Summary💡:
This code initializes useState by:

  • Storing the initial value in memoizedState.
  • Creating a queue for future updates.
  • Returning [the given value, a function to update it].

Detailed breakdown🔎:
In the mountStateImpl function, we use mountWorkInProgressHook, which checks whether this is the first hook in the component.

  • If it is the first hook, it is stored in memoizedState, which is part of Fiber.
  • If there was another hook before it, memoizedState will contain a special next field that links the new hook to the previous one.
  • This process continues until all hooks are added.

How does memoizedState look after initialization?

{
  "memoizedState": "useState under the hood: On Mount",
  "baseState": "useState under the hood: On Mount",
  "baseQueue": null,
  "queue": {
    "pending": null,
    "lanes": 0,
    "dispatch": null,
    "lastRenderedState": "useState under the hood: On Mount"
  },
  "next": null
}
Enter fullscreen mode Exit fullscreen mode

The next step is to create a queue to store future updates, but the hook is not added to the queue until the setState call (setStatus in our example)

Determining whether useState receives a primitive or a function:
React checks whether the value passed to useState is a primitive or a function:

if (typeof initialState === "function") {    
    var initialStateInitializer = initialState;
    initialState = initialStateInitializer();
}
hook.memoizedState = hook.baseState = initialState;
Enter fullscreen mode Exit fullscreen mode
  • If a primitive value is passed (e.g., 10 or "hello"), it is simply stored in memoizedState.
  • If a function is passed, it is executed, and its result is stored in memoizedState.

Creating the dispatch function 🚀
Once the hook's value is determined, React creates a function that will update this value.

This is the dispatch method, which is returned from useState as the second element of the array.

The dispatch function is created inside dispatchSetState:

function dispatchSetState(fiber, queue, action) {
  var lane = requestUpdateLane();
  dispatchSetStateInternal(fiber, queue, action, lane);
}
Enter fullscreen mode Exit fullscreen mode

The dispatchSetState function determines the update priority (lane) in Fiber and calls the dispatchSetStateInternal method (this method is quite large so we won't show it here).

dispatchSetStateInternal performs several important checks, including whether rendering is currently in progress to prevent an infinite loop of updates.

If a re-render is not occurring at that moment, React checks whether the new value differs from the previous one.

This optimization is crucial because if the value is identical, the re-render does not happen - React simply adds this update to the queue (updateQueue) to maintain the correct execution order of hooks.

How is the new value calculated?🧮
React determines whether we have passed a primitive value or a function.

function basicStateReducer(state, action) {
  return typeof action === "function" ? action(state) : action;
}

Enter fullscreen mode Exit fullscreen mode

How does this look in practice?

// primitive value
setStatus("useState under the hood: On Update");
// function 
setStatus((status) => `${status} - On Update`);
Enter fullscreen mode Exit fullscreen mode
  • In the first case, a primitive value is passed and stored in memoizedState.
  • In the second case, a function is passed, which receives the previous value and returns the updated one.

If the new value differs from the previous one, React updates the state object, adds the update to the queue (updateQueue), and triggers a re-render.

Until the re-render occurs, React accumulates all changes in the queue so that they can be executed in one pass while maintaining the correct execution order of hooks.

Re-rendering phase (renderWithHooks)🔄
At the re-rendering stage (renderWithHooks), React checks whether the state value already exists.

When the following line of code is executed:

const [status, setStatus] = useState("useState under the hood: On Mount");

React determines whether to use the existing value from memoizedState (updated) or initialize useState.

How does React determine whether to initialize the hook?
This happens inside renderWithHooks:

function renderWithHooks(
  current,
  workInProgress,
  Component,
  props,
  secondArg,
  nextRenderLanes
) {
  ReactSharedInternals.H =
    null === current || null === current.memoizedState
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;
  finishRenderingHooks(current);
  return nextRenderLanes;
}
Enter fullscreen mode Exit fullscreen mode

What happens here?

  • If current or current.memoizedState is null, React uses HooksDispatcherOnMount to initialize hooks.
  • If memoizedState already exists, React uses HooksDispatcherOnUpdate, which works with the current state.

Now, if memoizedState exists, useState calls updateReducer:

useState: function () {
  return updateReducer(basicStateReducer);
}
function basicStateReducer(state, action) {
  return typeof action === "function" ? action(state) : action;
}
Enter fullscreen mode Exit fullscreen mode

What does this mean?

  • useState uses updateReducer, where basicStateReducer updates the state.
  • This means that useState and useReducer operate on the same mechanism.
  • useState is a more convenient version of useReducer with a fixed reducer, basicStateReducer.

How is the state updated?
In updateReducer, memoizedState is updated, and after a re-render, we get a new value ready for use.

Analogy: Auto-save in a Video Game 🎮

As an analogy, we can imagine a computer game with an auto-save feature.
The game environment is our Fiber, which contains information about all the characters, their attributes, items, and enemies.
The entire game state can change, but the game will run slowly if we perform an auto-save after every single change.
Therefore, all changes (such as enemies losing health) are first added to a change queue (similar to updateQueue).

Auto-save (rendering) does not occur immediately after every action but waits for the right moment.
For example, when a player picks up a rare artifact that affects their health or experience.

This is similar to how setState works:
We can call setState, but React does not update all components immediately - instead, it accumulates changes and applies them at the most optimal moment.

This allows the game to run smoothly and React to efficiently update the UI!

Scheme of useState

useStateScheme

What is useEffect📄

According to React documentation, useEffect is a React Hook that lets you synchronize a component with an external system.
useEffect allowing us to perform side effects in functional components.
With it, we can:

  • Execute code after the initial render.
  • React to state or prop changes.
  • Perform cleanup before the component is unmounted

How does useEffect work under the hood?🌀

React processes useEffect in three main stages:

  • Initialization (mountEffectImpl)
  • Update (updateEffectImpl)
  • Execution (commitHookEffectListMount)

Initialization of useEffect and Setting Flags🚩
When a component is rendered for the first time, React registers the effect using mountEffectImpl:

useEffect: function (create, deps) {
  const PassiveEffect = 8390656;
  const HookHasEffect = 8;
  return mountEffectImpl(PassiveEffect, HookHasEffect, create, deps); 
}

Enter fullscreen mode Exit fullscreen mode

What do we have here?

  • create - The function passed to useEffect.
  • deps - The dependency array (null, [], or [arg1, …argN]).
  • PassiveEffect - Indicates that useEffect runs after the DOM update.
  • HookHasEffect - Signals that the effect should be executed.

React stores this effect in Fiber as an object:

{
  "memoizedState": {
    "create": function () { ... }, // Effect code
    "deps": [count], // Dependency array
    "destroy": null, // Cleanup function
    "next": null // Reference to the next effect
  }
}
Enter fullscreen mode Exit fullscreen mode

How Dependencies Work
If deps === null, the effect runs after every re-render.
If deps === [], the effect runs only once after the initial render.
If deps === [count], the effect runs whenever count changes.

Update (updateEffectImpl)🚀
When the component updates, React checks whether the effect needs to be re-run.

Key Code in updateEffectImpl:

function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
  var hook = updateWorkInProgressHook();
  deps = deps === undefined ? null : deps;
  var inst = hook.memoizedState.inst;

  if (
    currentHook !== null &&
    deps !== null &&
    areHookInputsEqual(deps, currentHook.memoizedState.deps)
  ) {
    // ❌ `deps` did not change → effect WILL NOT run!
    hook.memoizedState = pushEffect(hookFlags, create, inst, deps);
  } else {
    // ✅ `deps` changed → effect will run after DOM update!
    currentlyRenderingFiber$1.flags |= fiberFlags;
    hook.memoizedState = pushEffect(1 | hookFlags, create, inst, deps);
  }
}
Enter fullscreen mode Exit fullscreen mode

What happens here?

  1. React retrieves the previous dependencies
  2. It performs a check:

areHookInputsEqual(deps, currentHook.memoizedState.deps)

3.1. If deps did not change, the effect is added to Fiber without updating fiberFlags (if flags are NOT updated, the effect WILL NOT run).
3.2. If deps changed, fiberFlags are updated, signaling React to execute the effect in the commit phase.

The internal structure of areHookInputsEqual

function areHookInputsEqual(nextDeps, prevDeps) {
  if (prevDeps === null || nextDeps === null) {
    return false;
  }

  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (!Object.is(nextDeps[i], prevDeps[i])) {
      return false;
    }
  }
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Object.is() compares each dependency, and if at least one changed, React marks the effect for execution.

Executing useEffect (commitHookEffectListMount)

After the DOM update, React runs all effects that have the appropriate flags.

function commitHookEffectListMount(flags, finishedWork) {
  const updateQueue = finishedWork.updateQueue;
  if (updateQueue !== null) {
    const lastEffect = updateQueue.lastEffect;
    if (lastEffect !== null) {
      let effect = lastEffect.next;
      do {
        if ((effect.tag & flags) === flags) { // Checking `flags`
          var create = effect.create,
              inst = effect.inst;
          var cleanup = create(); // Running `useEffect`
          inst.destroy = cleanup; // Storing cleanup function
        }
        effect = effect.next;
      } while (effect !== lastEffect.next);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

How It Works:
React checks the flags:

if ((effect.tag & flags) === flags)

  • If flags contain PassiveEffect, the effect is executed.

1.1. Calls effect.create() (user-defined function inside useEffect).

1.2. If useEffect returns a cleanup function, it is stored in inst.destroy.

  • If flags are not set, the effect is skipped.

Step-by-Step Example of useEffect

function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("useEffect executed!");
    return () => console.log("Cleanup effect!");
  }, [count]);

return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => {
        setCount(() => { 
          if (count > 1) return count; 
          return count + 1; 
          })
        }}>
        Increment
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Execution Flow
First Render (count = 0)
deps = [0], prevDeps = null → Effect runs

Clicking the Button (count = 1)
deps = [1], prevDeps = [0] → Effect runs again.
Before executing the new effect, cleanup() is called.

Clicking the Button Again (count = 1 again)
deps = [1], prevDeps = [1] → Effect does not run.
areHookInputsEqual([1], [1]) === true

Analogy: Event Manager in a Video Game 🎮

Imagine our computer game has an event manager responsible for triggering various game mechanics.

However, this manager doesn't just execute all events at once - it follows specific rules to optimize performance and efficiency:

Observing Changes (deps)

The event manager constantly monitors external factors, such as the time of day or the collection of an important item.
For example, if night turns into day, this may trigger a new event - new characters appearing or environmental changes.
In React, this is similar to deps - if dependencies change, useEffect will execute.

Cleaning Up Before Executing a New Event (cleanup())

Before starting a new quest, the game automatically saves progress (auto-save).
Similarly, before switching to nighttime music, the game stops playing the daytime soundtrack.
This is analogous to cleanup() in useEffect, which runs before a new effect is executed.

Executing the Event (useEffect triggers)

If the event manager detects that external conditions have changed, it triggers a new effect.
For example, when night falls, the manager spawns monsters, changes the soundtrack, and activates night mechanics.
This is how useEffect works - it executes when dependencies change.

The Event Manager Keeps Running Until the Game Closes

Before exiting the game, the manager may perform final cleanup actions (like one last auto-save).
For instance, the game saves the progress again, so that the player returns to the same point next time.
This is similar to the final cleanup() that runs before a component is unmounted.

Congratulations

Congratulations! We've explored two of the most essential and widely used hooks in React - useState and useEffect - in great detail. I hope this deep dive helps you gain a better understanding of how hooks work under the hood and how to use them effectively in your projects.

Top comments (0)