Coding, Insights, and Digital Discoveries 👩🏻‍💻

From Stay-at-Home Mom to a Developer After 50

Published on

State Management in React: When to Go Modular vs. Global - A React Developer's Guide

state-management-in-react

Introduction

This article was inspired by my deep dive into Shadcn's open-source library, specifically its Toaster System. As I explored how the system handles toast notifications, I became fascinated by the techniques used for managing state and building a scalable, modular solution.

In this article, I’ll share what I’ve learned from Shadcn’s toaster system and how its design principles can be applied to building efficient state management solutions in React.


React developers often face a common challenge: managing dynamic UI components across an application. Whether it’s toast notifications, modals, or tooltips, creating a reusable, scalable, and centralized state management system can save time and reduce complexity.

The Need for Reusable State Management

React is a declarative library, meaning you describe what the UI should look like based on the current state, and React automatically takes care of how to update the UI when that state changes. This makes it easier to build dynamic interfaces. However, when multiple components need to share dynamic behaviors or interact with each other across an entire application, managing global state becomes more challenging. The complexity arises because changes to shared state can affect multiple parts of the app, making it harder to keep everything in sync. Toast notifications—small, transient messages used to inform users—are a great case study for this:

  • Toasts are ephemeral and can appear anywhere.
  • Their creation and dismissal need to be managed centrally.
  • Multiple components may need to trigger or observe them.

The useToast hook from Shadcn elegantly solves this problem. Let’s dive into its architecture and see how it achieves modularity and reusability.

The Toast Notification Pattern

Here’s a typical logic for useToast setup :

  1. toast(): A function to create a new toast with properties like message, action, and type.
  2. useToast(): A hook that provides access to all active toasts and utility methods like dismiss.
  3. Centralized State Management: Using a combination of reducers and global listeners to track and update toast state.

Let’s start by reviewing the original implementation.

Code Walkthrough: Building useToast

Here’s a full implementation of the useToast system from Shadcn with some minor adjustments:

1. The Core Logic

This code defines the structure and behavior of our toast system:

"use client";

import * as React from "react";

// Toast type definitions
type ToastActionElement = React.ReactNode;

interface ToastProps {
  id: string;
  title?: React.ReactNode;
  description?: React.ReactNode;
  action?: ToastActionElement;
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
}

const TOAST_LIMIT = 3; // Maximum number of toasts visible
const TOAST_REMOVE_DELAY = 5000; // Time before toast is auto-removed

type ToasterToast = ToastProps;

const actionTypes = {
  ADD_TOAST: "ADD_TOAST",
  UPDATE_TOAST: "UPDATE_TOAST",
  DISMISS_TOAST: "DISMISS_TOAST",
  REMOVE_TOAST: "REMOVE_TOAST",
} as const;

type Action =
  | { type: typeof actionTypes.ADD_TOAST; toast: ToasterToast }
  | { type: typeof actionTypes.UPDATE_TOAST; toast: Partial<ToasterToast> }
  | { type: typeof actionTypes.DISMISS_TOAST; toastId?: string }
  | { type: typeof actionTypes.REMOVE_TOAST; toastId?: string };

interface State {
  toasts: ToasterToast[];
}

// Reducer function to manage toast state
const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case actionTypes.ADD_TOAST:
      return {
        ...state,
        toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
      };
    case actionTypes.UPDATE_TOAST:
      return {
        ...state,
        toasts: state.toasts.map((t) =>
          t.id === action.toast.id ? { ...t, ...action.toast } : t
        ),
      };
    case actionTypes.DISMISS_TOAST:
      const toastId = action.toastId;
      return {
        ...state,
        toasts: state.toasts.map((t) =>
          t.id === toastId || toastId === undefined
            ? { ...t, open: false }
            : t
        ),
      };
    case actionTypes.REMOVE_TOAST:
      return {
        ...state,
        toasts: state.toasts.filter((t) => t.id !== action.toastId),
      };
    default:
      return state;
  }
};

2. The toast() Function

toast() is the API for creating new toasts. Each toast is assigned a unique ID and an onOpenChange handler:

let count = 0;
function genId() {
  return (++count).toString();
}

function toast(props: Omit<ToasterToast, "id">) {
  const id = genId();

  const update = (newProps: Partial<ToasterToast>) => {
    dispatch({ type: actionTypes.UPDATE_TOAST, toast: { ...newProps, id } });
  };

  const dismiss = () => {
    dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id });
  };

  dispatch({
    type: actionTypes.ADD_TOAST,
    toast: {
      ...props,
      id,
      open: true,
      onOpenChange: (open) => {
        if (!open) dismiss();
      },
    },
  });

  return { id, update, dismiss };
}

NOTE

Notice the ToasterToast type has an id property, which is essential for identifying and managing toasts within the state. Each toast must have a unique id to allow operations like updating, dismissing, and removing specific toasts. When creating a toast using toast(), the id is removed (omitted) from the type. This makes it clear that the id is managed internally and not something the user has to supply. However, The toast() function is the user-facing API for creating a toast. Internally, the system needs a unique id to manage each toast, so the function generates one with genId() when a toast is created. This id is then added to the ToasterToast object before it is dispatched into the state.

TIP

The toast() function is used to create a new toast and returns an object with methods dismiss() and update() as well as the toast id. This is a very convenient design. Instead of tracking the id of the toast manually, the returned methods are preconfigured to operate on the correct id. This simplifies the developer experience. For example:

const myToast = toast({ title: "Hello" });
// Update the toast after 2 seconds
setTimeout(() => {
  myToast.update({ description: "Updated description!" });
}, 2000);

// Dismiss the toast after 5 seconds
setTimeout(() => {
  myToast.dismiss();
}, 5000);

You see, a new Toast is created with an internal id. It can then be update or dismiss without mannually checking its id.

3. The useToast Hook

This hook allows components to observe the toast state and trigger actions:

const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };

function dispatch(action: Action) {
  memoryState = reducer(memoryState, action);
  listeners.forEach((listener) => listener(memoryState));
}

function useToast() {
  const [state, setState] = React.useState(memoryState);

  React.useEffect(() => {
    listeners.push(setState);
    return () => {
      listeners.splice(listeners.indexOf(setState), 1);
    };
  }, []);

  return {
    ...state,
    toast,
    dismiss: (toastId?: string) => dispatch({ type: actionTypes.DISMISS_TOAST, toastId }),
  };
}

export { useToast, toast };

NOTE

  • Within the useToast() hook, the initial value of state is set to memoryState, which is a shared state object that tracks the toasts globally.
  • listeners is an array of callback functions that are called whenever the global memoryState changes.
  • The current setState function is added to listeners array, ensuring that useToast updates its global state whenever memoryState changes.
  • return () => { listeners.splice(listeners.indexOf(setState), 1); }; is a cleanup function: When the component using this hook unmounts, the setState function is removed from listeners, preventing memory leaks or unnecessary updates.
  • The useToast hook returns an object with three main properties: 1. toasts (from state.toasts):An array of currently active ToasterToast objects. Each object contains properties like id, title, description, open, and any actions. 2. toast: A function to create new toasts, with properties like title and description. 3. dismiss: A function to dismiss a specific toast (by id) or all toasts (if no id is provided).

TIP

The useToast() return a dismiss(), this is not scoped to a single toast. Instead, it provides the ability to dismiss either a specific toast by id or all toasts when no id is provided, because dismiss: (toastId?: string) line of code says toastId is optional.

Complementary behavior: While the toast() method's dismiss() is scoped to the specific toast it creates, the dismiss function in useToast() provides broader functionality to interact with any toast or all toasts.

Using the useToast Hook

Here’s how you can use the system in your React components:

import { useToast, toast } from "./use_toast";

function App() {
  const { toasts, dismiss } = useToast();

  return (
    <div>
      <button onClick={() => toast({ title: "Hello!", description: "This is a toast." })}>
        Show Toast
      </button>
      <div>
        {toasts.map((t) => (
          <div key={t.id} style={{ border: "1px solid black", margin: "10px" }}>
            <h4>{t.title}</h4>
            <p>{t.description}</p>
            <button onClick={() => dismiss(t.id)}>Dismiss</button>
          </div>
        ))}
      </div>
    </div>
  );
}

Beyond Toasts: Other Applications

The pattern used to design the toast-generating and managing API is highly modular and well-suited for any system where global state management and reusability are critical. Below are some systems that could be designed using a similar pattern:

1. Modal Management 🗂️

  • Purpose : Dynamically manage modals (dialog boxes) across the app.

  • How it works :

    • A global modal manager tracks the state of all active modals.
    • A showModal() function can create modals dynamically, with options for passing content, callbacks, or configurations (e.g., size, type).
    • A useModal() hook can provide state and actions for global modal management.
import { useModal, showModal } from "./use_modal";

function App() {
  const { modals, closeModal } = useModal();

  return (
    <div>
      {modals.map((m) => (
        <div key={m.id} style={{ display: m.open ? "block" : "none" }}>
          <div>{m.content}</div>
          <button onClick={() => closeModal(m.id)}>Close</button>
        </div>
      ))}
      <button
        onClick={() =>
          showModal({ content: <div>Hello Modal!</div>, onClose: () => console.log("Closed!") })
        }
      >
        Show Modal
      </button>
    </div>
  );
}

2. Tooltips 🛠️

  • Purpose : Dynamically manage tooltips for contextual help or additional information.

  • How It Works :

    • Tooltips can be shown dynamically using a showTooltip() function, with properties like position, content, and targetElement.
    • A useTooltip() hook provides access to active tooltips and allows manual dismissal or updates.
import { useTooltip, showTooltip } from "./use_tooltip";

function App() {
  const { tooltips, hideTooltip } = useTooltip();

  return (
    <div>
      {tooltips.map((t) => (
        <div
          key={t.id}
          style={{
            position: "absolute",
            top: t.position.y,
            left: t.position.x,
          }}
        >
          {t.content}
          <button onClick={() => hideTooltip(t.id)}>Hide</button>
        </div>
      ))}
      <button
        onMouseEnter={(e) =>
          showTooltip({
            content: "This is a tooltip!",
            position: { x: e.clientX, y: e.clientY },
          })
        }
      >
        Hover Me
      </button>
    </div>
  );
}

3. Global Error Handling ❎

  • Purpose : Centralize error logging and display across an application.

  • How It Works :

    • Errors are captured globally and pushed to a centralized error log.
    • A reportError() function allows manual reporting of errors.
    • A useError() hook provides a list of all logged errors and methods to dismiss or mark them as resolved.
import { useError, reportError } from "./use_error";

function App() {
  const { errors, resolveError } = useError();

  const throwError = () => {
    try {
      throw new Error("Something went wrong!");
    } catch (e) {
      reportError({ message: e.message, stack: e.stack });
    }
  };

  return (
    <div>
      {errors.map((e) => (
        <div key={e.id}>
          <p>{e.message}</p>
          <button onClick={() => resolveError(e.id)}>Resolve</button>
        </div>
      ))}
      <button onClick={throwError}>Throw Error</button>
    </div>
  );
}

4. Notification System 📢

  • Purpose : Display user notifications like messages, alerts, or updates across the app.

  • How it works : Notifications are global by nature, making centralized state management ideal. Components can trigger notifications with a notify() function, passing in properties like type (success, error, info) and message content. A global useNotification() hook can provide a list of active notifications and actions to dismiss or update them.

import { useNotification, notify } from "./use_notification";

function App() {
  const { notifications, dismiss } = useNotification();

  return (
    <div>
      {notifications.map((n) => (
        <div key={n.id}>
          <p>{n.message}</p>
          <button onClick={() => dismiss(n.id)}>Dismiss</button>
        </div>
      ))}
      <button
        onClick={() => notify({ type: "success", message: "Operation successful!" })}
      >
        Show Notification
      </button>
    </div>
  );
}

5. Dynamic Form Management System 📇

import { useForm, registerForm } from "./use_form";

function App() {
  const { forms, updateField, submitForm } = useForm();

  const createForm = () => {
    registerForm("loginForm", { username: "", password: "" });
  };

  return (
    <div>
      <button onClick={createForm}>Create Form</button>
      {forms.map((form) =>
        form.id === "loginForm" ? (
          <div key={form.id}>
            <input
              value={form.fields.username}
              onChange={(e) => updateField("loginForm", "username", e.target.value)}
            />
            <input
              type="password"
              value={form.fields.password}
              onChange={(e) => updateField("loginForm", "password", e.target.value)}
            />
            <button onClick={() => submitForm("loginForm")}>Submit</button>
          </div>
        ) : null
      )}
    </div>
  );
}

6. Global Command Palette 🎨

  • Purpose : Provide a centralized, dynamic command palette (like VS Code’s Command Palette).

  • How It Works :

    • Commands are registered globally with a registerCommand() function.
    • A useCommandPalette() hook provides access to available commands and triggers their execution.
import { useCommandPalette, registerCommand } from "./use_command_palette";

function App() {
  const { commands, executeCommand } = useCommandPalette();

  const registerExampleCommand = () => {
    registerCommand("sayHello", { description: "Say Hello", execute: () => alert("Hello!") });
  };

  return (
    <div>
      <button onClick={registerExampleCommand}>Register Command</button>
      <div>
        {commands.map((cmd) => (
          <div key={cmd.id}>
            <p>{cmd.description}</p>
            <button onClick={() => executeCommand(cmd.id)}>Run</button>
          </div>
        ))}
      </div>
    </div>
  );
}

Lessons Learned

You must have noticed that there are some common patterns across all these systems.

1. Global State : Use an in-memory state or a context to store the entities (toasts, modals, notifications, etc.).

2. Centralized Actions : Provide functions (e.g., toast(), showModal(), registerCommand()) to interact with the global state.

3. Reactive State Access : A hook (useToast(), useModal(), etc.) that allows components to subscribe to state changes and provides utility methods for management.

4. Encapsulation : Keep all logic centralized to avoid redundancy and ensure scalability.


By studying the useToast system, I’ve discovered a powerful pattern for managing dynamic UI components in React—combining a centralized state with hooks. This approach not only scales beautifully for scenarios like toast notifications but can also be adapted to other systems such as modals and dialogs.

By implementing this pattern, we can design flexible, modular systems for any scenario that requires the dynamic creation and global management of stateful entities. It encourages better organization, ease of integration, and maintainability, making it a valuable technique for React developers.

Try applying this approach in your projects, and you’ll see how it transforms your code into a more modular and manageable structure.

← See All Posts