How to debug state updates like a pro (without polluting output)

by SkillAiNest

When you’re debugging a large React codebase, you can start to feel like a spy. Especially when you’re looking for unexpected state changes, components that reproduce when they like, or context values ​​that disappear into thin air without warning.

And the underlying difficulty is not necessarily wrong.

React provides powerful ways to change state, but it doesn’t know who or what is causing those changes. In large apps with many layers of components, hooks, and context, this lack of insight can turn simple bugs into frustrating, time-consuming puzzles.

This is where more advanced debugging methods become important. Before, the go-to solution was spraying console.log Call out key points or fall back on detours.

But these days, you can write a small but powerful utility function that can catch criminals committing crimes against your codebase. This utility function can log changes, display meaningful stack traces, and easily work with useStatefor , for , for , . useReducercontext providers, and custom hooks. And all of the above can be hidden in production.

This article guides you through how to use this helper function to improve precision, reduce guesswork, and debug efficiently without affecting performance or code cleanliness in your live environment.

Table of Contents

The problem

A reactive state system is powerful, but it hides a lot of information when something goes wrong—for example, when an unexpected update occurs or a component endlessly re-renders. Reaction doesn’t tell you what triggered the update, what Change, or Why? It happened. This lack of visibility creates several challenges.

The first is that you cannot easily see which component, function, or effect initiated the state update. In large applications, where the same state can be changed from multiple locations, this allows faster debugging. Without clear signs, developers often splurge console.log throughout their code to find the source of a single update.

Secondly, React lacks a built-in method to directly compare previous and current values. This complicates diagnosing whether a bug stems from a miscalculation, a poor API response, or incorrect business logic. The challenge increases with nested objects, arrays, or shared contexts.

Third, context updates can trigger repeaters throughout the tree, even for components wrapped in memories. But does not explain the reaction Why? A particular supplier changed, leaving the teams to wonder what triggered the cascade.

Finally, infinite loops do not provide any clues in the console due to effects, volatile dependencies, or repeated set state calls. You just see symbols like “Loading…” repeating endlessly, with no indication of the source.

All of this makes debugging complex React apps frustrating, slow, and often misleading without additional tools or structuring techniques.

Why does this problem exist?

The framework intentionally hides its internal update process to make it faster and more predictable.

Because of this:

  • setState() Does not report where he was called from

  • Context repeaters can be spawned from anywhere.

  • State overrides can happen silently.

  • Debugging often relies on adding console logs manually.

In large applications, the lack of visibility makes it almost impossible to track unexpected state changes.

My solution: createDebugSetter

A small helper function, createDebugSetterwrap your state setter and login:

And the best part is that it automatically disables itself in production NODE_ENV So there is no impact on your live app.

createdebugsetter

export function createDebugSetter(
  label: string,
  setter:
    | React.Dispatch>
    | React.Dispatch
): React.Dispatch<React.SetStateAction<unknown>> | React.Dispatch<unknown> 
  
  if (import.meta.env.PROD )  null;
  setUser: React.Dispatchnull>>;
  login: (name: string, email: string) => void;
  logout: () => void;


  
  return (value: React.SetStateAction 

The above function is a Debugging the wrapper for state setters which is logged during development.

It takes two parameters: a label to identify and setState The function you want to debug. In development mode, each state update triggers a debuggable console log that shows the new value of the update origin and a stack trace. In production, the hook completely skips and leaves the original setter unchanged, which ensures zero runtime overhead in deployed applications.

How it does:

 
  if (import.meta.env.PROD )  null;
  setUser: React.Dispatchnull>>;
  login: (name: string, email: string) => void;
  logout: () => void;

In production, createDebugSetter The function returns the reaction setState As it is. This is because we don’t want to log anything while our code is running in a production environment. Here, we are using import.meta.env.PROD By reaction. It returns a Boolean The value that tells us whether it is in production environment or not.

If it’s not in production, we return the modified setter below.


  return (value: React.SetStateAction | unknown) => {
    
    console.groupCollapsed(
      `%c🔄 (${label}) State Update`,
      "color: #adad01; font-weight: bold;"
    );
    console.log("🆕 New value:", value);
    console.trace("📍 Update triggered from:");
    console.groupEnd();

    
    setter(value);
  };

This new setter function first logs a collapsible console group with a label and an emoji. Then it shows the new price setting. After that, it displays a stack trace showing where the update was triggered. Finally, it calls the original setter to actually update the state.

Working examples of createdebugsetter

Now let’s see how createDebugSetter Can be used in many places within a code base.

Context providers

you can use createDebugSetter Within a context provider to log changes to state setState is called This can help keep track of every login and state changes setState Called in the context provider anywhere in the application.

import { createContext, useContext, useState, type ReactNode } from "react";
import { createDebugSetter } from "../utils/createDebugSetter";

interface User {
  name: string;
  email: string;
  role: string;
}

interface UserContextType {
  user: User | null;
  setUser: React.Dispatchnull>>;
  login: (name: string, email: string) => void;
  logout: () => void;
}

const UserContext = createContextundefined>(undefined);

export function UserProvider({ children }: { children: ReactNode }) {
  const (user, setUserOriginal) = useStatenull>(null);

  
  const setUser = createDebugSetter(
    "UserContext",
    setUserOriginal
  ) as React.Dispatchnull>>;

  const login = (name: string, email: string) => {
    setUser({
      name,
      email,
      role: "user",
    });
  };

  const logout = () => {
    setUser(null);
  };

  return (
    
      {children}
    
  );
}

In the above code sample, we create a modified setUserOriginal It is called setUser who uses createDebugSetter Under the hood we are then exposed to the context value instead setUserOriginal .

whenever setUser That said, it’s dynamic createDebugSetter who does the job of checking the environment from which the code is running, and returns a modified setter that will call setUserOriginal After the logging process, or will return setUserOriginal As it is.

This is useful because context updates can trigger many renderers. It shows exactly who changed the common state.

usestate

As you saw in the context provider example above, we can use the same technique in regular components that use state settings (just like in context providers). We track logs and value. It also indicates where it was triggered from within the component or application.

import { useState } from "react";
import { useDebugSetter } from "../hooks/useDebugSetter";

export function UseStateExample() {
  const (count, setCountOriginal) = useState(0);
  const (name, setNameOriginal) = useState("React");

  
  const setCount = useDebugSetter("Counter", setCountOriginal);
  const setName = useDebugSetter("Name", setNameOriginal);

  const handleIncrement = () => {
    setCount(count + 1);
  };

  const handleDecrement = () => {
    setCount(count - 1);
  };

  const handleNameChange = () => {
    setName(name === "React" ? "Vite" : "React");
  };

  return (
    
"20px", border: "1px solid #ccc", borderRadius: "8px", margin: "10px", }} >

useState Example

Open the console to see debug logs when state changes.

"15px" }}>

Count: {count}

"15px" }}>

Name: {name}

); }

It works just like context providers. The only differences are the components used setCount And setName Out-of-the-box functions in buttons and related components. Also, unlike the context provider, this component has local state that can be passed to its child components when needed.

It is ideal for monitoring dynamic loops by unexpected local state changes or effects.

User Deser

Reactive reducers are used to perform complex logic calculations before updating the state. It can introduce unwanted side effects during the complex phase. createDebugSetter As shown below, can help with debugging:

import { useReducer } from "react";
import { createDebugSetter } from "../utils/createDebugSetter";

interface CounterState {
  count: number;
  step: number;
}

type CounterAction =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "reset" }
  | { type: "setStep"; step: number };

function counterReducer(
  state: CounterState,
  action: CounterAction
): CounterState {
  switch (action.type) {
    case "increment":
      return { ...state, count: state.count + state.step };
    case "decrement":
      return { ...state, count: state.count - state.step };
    case "reset":
      return { ...state, count: 0 };
    case "setStep":
      return { ...state, step: action.step };
    default:
      return state;
  }
}

export function UseReducerExample() {
  const (state, dispatchOriginal) = useReducer(counterReducer, {
    count: 0,
    step: 1,
  });

  
  const dispatch = createDebugSetter("CounterReducer", dispatchOriginal);

  return (
    
"20px", border: "1px solid #ccc", borderRadius: "8px", margin: "10px", }} >

useReducer Example

Open the console to see debug logs for reducer actions.

"15px" }}>

Count: {state.count}

Step: {state.step}

"10px" }}>

); }

dispatchOriginalwhich is the main dispatch function, has been replaced with a custom function called dispatch who uses createDebugSetter. When custom dispatch The function is called, it does the work createDebugSetter And by extension, the function of dispatchOriginal .

It is perfect for logging deductive actions and understanding complex state transitions.

Custom hooks

Custom hooks are not left out of the equation, as they can use setState In some cases. They are also capable of running complex logic that can backfire during updates state.

import { useState, useEffect } from "react";
import { useDebugSetter } from "../hooks/useDebugSetter";


function useTimer(initialSeconds: number = 0) {
  const (seconds, setSecondsOriginal) = useState(initialSeconds);
  const (isRunning, setIsRunningOriginal) = useState(false);

  
  const setSeconds = useDebugSetter("Timer.seconds", setSecondsOriginal);
  const setIsRunning = useDebugSetter("Timer.isRunning", setIsRunningOriginal);

  useEffect(() => {
    if (!isRunning) return;

    const interval = setInterval(() => {
      setSeconds((prev) => prev + 1);
    }, 1000);

    return () => clearInterval(interval);
  }, (isRunning, setSeconds));

  const start = () => setIsRunning(true);
  const stop = () => setIsRunning(false);
  const reset = () => {
    setSeconds(0);
    setIsRunning(false);
  };

  return {
    seconds,
    isRunning,
    start,
    stop,
    reset,
  };
}

export function CustomHookExample() {
  const timer = useTimer(0);

  const formatTime = (seconds: number) => {
    const mins = Math.floor(seconds / 60);
    const secs = seconds % 60;
    return `${mins.toString().padStart(2, "0")}:${secs
      .toString()
      .padStart(2, "0")}`;
  };

  return (
    
"20px", border: "1px solid #ccc", borderRadius: "8px", margin: "10px", }} >

Custom Hook Example

Open the console to see debug logs for internal hook state changes.

"15px" }}>

"24px", fontWeight: "bold" }}> {formatTime(timer.seconds)}

Status: {timer.isRunning ? "Running" : "Stopped"}

"15px" }}>

); }

As shown in the previous examples, setSecondsOriginal And setIsRunningOriginal has been replaced with setSeconds And setIsRunning. The latter uses createDebugSetter Helper function. This enables console log statements to be better printed every second.

Custom hooks often hide multiple internal updates, making it difficult to see where each one starts.

Best practice to use createDebugSetter

When using helper functions such as createDebugSetterit’s best to keep in mind why you’re actually using them. For our purpose here, we’re using it to debug a React request. So I will share some tips that will help in this debugging process.

Use clear labels

Using labels that can say createDebugSetter It is a step in the right direction. Detailed labels will help you better understand where and why the problem may occur. Also, keep in mind that createDebugSetter A utility function can be used in multiple places in your application, and improper labeling can make debugging difficult.

Using the name of the component or region that refers to it as a label createDebugSetter As shown below, can also be a good pointer for clear labeling.


createDebugSetter("aaa", setUser)
createDebugSetter("1", setUser)


createDebugSetter("UserContextProvider", setUser)
createDebugSetter("From UserContextProvider", setUser)

createDebugSetter("From UserContextProvider in user-context.tsx file", setUser)

use createDebugSetter Only in dev mode

By using createDebugSetter A development environment alone can prevent many headaches. It is not good practice to accidentally expose or log sensitive data in production. Also, logging in production can cause clutter.

use createDebugSetter With React Detools

createDebugSetter May not be enough for some complex bugs. you can use createDebugSetter And react to Detools for more powerful/complete debugging sessions. Although createDebugSetter React cannot be directly integrated with Detools, it shows who triggered the update, while React Detools was re-rendered.

place createDebugSetter i utils

createDebugSetter There is a utility function, as I mentioned above. That means you should keep it in one utils folder so that any team member can access and use it if needed in your React application.

To avoid things

  1. Avoid using debug setters in production builds. While they are safe, unnecessary logs can slow down debugging tools. Also, sensitive credentials can be logged by mistake. There are professional tools you can use, such as Sentry, that let you track errors and debug your app easily.

  2. Don’t conditionally wrap setter functions inside components. Wrap renders outside to prevent the creation of new setter identifiers.

  3. Do not rely on this to replace the appropriate state architecture. This tool helps identify problems, but does not fix poor state design.

  4. Do not rely solely on console logs. Use it as part of a broader debugging workflow, not as a stand-alone strategy.

Bonus: How to Change createDebugSetter to make a hook

Convert to a hook

simple createDebugSetter function works, but when used inside react components it generates a new wrapper function on each render. By converting it to a custom hook useCallbackwe can ensure that the wrapper function maintains stable references across iterators, preventing unnecessary performance and making it safe to use in dependent arrays.

Here is the hook version:

import { useCallback } from "react";

export function useDebugSetter<T>(
  label: string,
  setState: React.Dispatch>
): React.Dispatch<React.SetStateAction<T>> {
  const debugSetter = useCallback(
    (newValue: React.SetStateAction) => {
      
      if (!import.meta.env.PROD) {
        console.groupCollapsed(
          `%c🔄 State Update: ${label}`,
          "color: #2fa; font-weight: bold;"
        );
        console.log("🆕 New value:", newValue);
        console.trace("📍 Update triggered from:");
        console.groupEnd();
      }

      setState(newValue);
    },
    (label, setState)
  );

  
  
  return import.meta.env.PROD ? setState : debugSetter;
}

How does the hook version work?

The main difference between useDebugSetter Hook and createDebugSetter is that the function is wrapped in a useCallback It logs the information before calling the actual setter. Other than that, all other components of the functions remain the same.

Why the hook version is better

The hook version is super good for using the component because it leverages useCallback Debug wrapper memory. This means that the function reference stays the same, avoiding possible re-rendering cascades when the setter is passed or used in child components. useEffect Dependency

In contrast, the simple function creates a completely new wrapper on each render, which can break reactive optimization strategies and cause subtle bugs. In production, both versions just return the original setter, so there’s no performance difference – but in development, the hook prevents unnecessary work.

When using a hook

use useDebugSetter Whenever you are inside a React component and need to debug state updates. It covers most of the cases: wrapping useState Setters, moving debug setters to child components, or adding them to functional dependencies.

Just reach for the field createDebugSetter When you are working entirely outside of React components, such as utility modules, global stores, or configuration files where hooks cannot be used. For day-to-day component debugging, the hook is the right choice.

The result

Debugging react state doesn’t need to be guessed. With a simple wizard, you can quickly see what changed, who changed it, where the change originated, and how your app got to that state – all without touching your production environment.

This little utility function can save you hours of searching through your codebase, allowing you to be faster, more precise, and more confident in your React application behavior.

Once you adopt this approach, you’ll never debug the old way again. 🚀

You may also like

Leave a Comment

At Skillainest, we believe the future belongs to those who embrace AI, upgrade their skills, and stay ahead of the curve.

Get latest news

Subscribe my Newsletter for new blog posts, tips & new photos. Let's stay updated!

@2025 Skillainest.Designed and Developed by Pro