Coding, Insights, and Digital Discoveries 👩🏻💻
From Stay-at-Home Mom to a Developer After 50
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.
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:
The useToast
hook from Shadcn elegantly solves this problem. Let’s dive into its architecture and see how it achieves modularity and reusability.
Here’s a typical logic for useToast
setup :
toast()
: A function to create a new toast with properties like message
, action
, and type
.useToast()
: A hook that provides access to all active toasts and utility methods like dismiss
.Let’s start by reviewing the original implementation.
useToast
Here’s a full implementation of the useToast
system from Shadcn with some minor adjustments:
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;
}
};
toast()
Functiontoast()
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.
useToast
HookThis 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
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.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.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.
useToast
HookHere’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>
);
}
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:
Purpose : Dynamically manage modals (dialog boxes) across the app.
How it works :
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>
);
}
Purpose : Dynamically manage tooltips for contextual help or additional information.
How It Works :
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>
);
}
Purpose : Centralize error logging and display across an application.
How It Works :
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>
);
}
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>
);
}
Purpose : Manage multiple dynamic forms or fields globally in React.
How It Works :
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>
);
}
Purpose : Provide a centralized, dynamic command palette (like VS Code’s Command Palette).
How It Works :
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>
);
}
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.