Mastering Transient State: Building a Real-time Process Sidebar in React
Ever needed to show short-lived, client-side processes in your React app without a full backend? This post dives into how we built a real-time ephemeral process sidebar using useSyncExternalStore, navigated TypeScript quirks, and tackled classic React stale closure issues.
Just wrapped up a session where the goal was to bring a much-needed UX improvement to our app: real-time feedback for client-side operations. Specifically, we wanted to show progress for "enrichment" processes directly in the sidebar. This might sound straightforward, but handling transient, client-side state, especially with React's rendering model and TypeScript's strictness, always brings its own set of interesting challenges.
By the end of the session, we had a robust system for ephemeral client-side processes, complete with a slick UI integration, comprehensive unit tests, and a clean type-check. Let's break down how we got there, including the bumps in the road and the lessons learned.
The Goal: Real-time Feedback for Ephemeral Processes
Imagine you're enriching a note with AI-generated wisdom. This isn't a long-running background job that needs a database entry; it's a quick, client-side operation that might take a few seconds. We needed a way to:
- Visually indicate that an ephemeral process is active.
- Show its progress or status.
- Automatically remove it when complete or cancelled.
- Do all of this efficiently and without unnecessary re-renders.
The chosen solution was to create a dedicated store for these "ephemeral processes" and hook it into our main sidebar.
Building the Ephemeral Process Store
The heart of this feature is src/lib/ephemeral-processes.tsx. We opted for a custom store pattern, leveraging React's useSyncExternalStore hook. This hook is perfect for integrating with external, mutable stores and ensuring efficient updates without triggering full component re-renders unless the snapshot actually changes.
Here's a simplified look at the store's structure:
// src/lib/ephemeral-processes.tsx
import { useSyncExternalStore } from 'react';
export type ProcessType = 'enrichment' | 'export' | 'analysis'; // Example types
export interface EphemeralProcess {
id: string;
type: ProcessType;
message: string;
progress?: number; // 0-100
timestamp: number;
}
interface EphemeralProcessStore {
add: (process: EphemeralProcess) => void;
remove: (id: string) => void;
getSnapshot: () => EphemeralProcess[];
subscribe: (listener: () => void) => () => void;
}
// Internal array to hold processes
let processes: EphemeralProcess[] = [];
const listeners = new Set<() => void>(); // Using Set here was a lesson learned (see below)
export function createStore(): EphemeralProcessStore {
return {
add: (newProcess) => {
// Deduplicate by ID
processes = [...processes.filter(p => p.id !== newProcess.id), newProcess];
listeners.forEach(listener => listener());
},
remove: (id) => {
const initialLength = processes.length;
processes = processes.filter(p => p.id !== id);
if (processes.length < initialLength) { // Only notify if something actually changed
listeners.forEach(listener => listener());
}
},
getSnapshot: () => processes,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
// This is the store instance used throughout the app
const ephemeralProcessStore = createStore();
// React Context for providing the store and a custom hook for consumption
export const EphemeralProcessProvider = ({ children }: { children: React.ReactNode }) => {
// We'll see why useState is used here below!
const [store] = React.useState(() => createStore());
// ... provide store via context
};
export const useEphemeralProcesses = () => {
const store = React.useContext(EphemeralProcessContext); // Get store from context
return useSyncExternalStore(store.subscribe, store.getSnapshot);
};
This setup allows any component to useEphemeralProcesses() and get a stable, up-to-date list of active processes, triggering re-renders only when add or remove is called.
UI Integration: The Pulsing Sparkles
With the store in place, the next step was to integrate it into the UI. src/components/layout/active-processes.tsx now consumes useEphemeralProcesses() and renders them in the sidebar. To make these transient processes stand out, we gave them a distinct visual identity:
- Sparkles icon: For that "magic happens" feel.
- Cyan color: Bright and attention-grabbing.
- Pulsing progress bar: A subtle animation to indicate ongoing activity.
This provides immediate, clear feedback to the user without being overly intrusive. We also extracted a typeBarColors map to keep the styling organized.
Real-World Application: Notes Enrichment
The NotesTab enrichment feature was the first real consumer of this new system. When a user triggers an enrichment, we now add an entry to the ephemeralProcessStore.
// Simplified snippet from src/app/(dashboard)/dashboard/projects/[id]/page.tsx
const ephemeralProcesses = useEphemeralProcesses(); // Access the store
const enrichingRef = React.useRef<string | null>(null); // For unmount cleanup
const { mutate: enrichNote } = useEnrichNoteMutation({
onMutate: (noteId) => {
// Add process to store immediately
ephemeralProcessStore.add({
id: noteId,
type: 'enrichment',
message: 'Enriching with wisdom...',
progress: 0,
timestamp: Date.now(),
});
enrichingRef.current = noteId; // Store ID for unmount cleanup
},
onSettled: (data, error, variables) => {
// Crucial for cleanup: `variables.id` is in local scope here!
ephemeralProcessStore.remove(variables.id);
enrichingRef.current = null;
},
// ... other options
});
// Cleanup on unmount or if a cancel button is clicked
React.useEffect(() => {
return () => {
if (enrichingRef.current) {
ephemeralProcessStore.remove(enrichingRef.current);
}
};
}, []);
This snippet highlights a few critical points for robustness:
onMutatefor immediate feedback: The process appears in the sidebar as soon as the mutation starts.onSettledfor guaranteed cleanup: Whether the enrichment succeeds or fails,onSettledensures the process is removed from the store. Crucially, we usevariables.idfrom the callback arguments to avoid stale closure issues (more on this below!).- Unmount cleanup: A
useRefis used to track theenrichingIdin case the component unmounts before the process completes, ensuring no orphaned processes. - Cancel button cleanup: (Not shown in snippet) Any explicit cancel action also triggers
ephemeralProcessStore.remove(id).
Battling the Compiler & Runtime: Lessons Learned
No dev session is complete without a few head-scratchers. Here are the "pain points" that turned into valuable lessons:
1. useRef<StoreType>(null) with TS2540
The Problem: I initially tried to create the ephemeralProcessStore instance inside a useRef within the EphemeralProcessProvider:
// Attempted:
const storeRef = useRef<EphemeralProcessStore | null>(null);
if (!storeRef.current) {
storeRef.current = createStore(); // TS2540: Cannot assign to 'current' because it is a read-only property.
}
const store = storeRef.current;
TypeScript correctly pointed out TS2540. When you initialize useRef with null, its current property is typed as T | null. While you can assign to it in JavaScript, TypeScript's stricter interpretation for null initializers often makes current effectively read-only in a way that prevents this pattern if you want to guarantee T later without casting. It's a common confusion point.
The Solution: The idiomatic React way to create a value once and have it persist across renders is useState with a functional initializer:
// Solution:
const [store] = React.useState(() => createStore());
This guarantees createStore() is called only once on the initial render, and store always holds the stable instance. Simple, clean, and type-safe.
2. Set Iteration & --downlevelIteration (TS2802)
The Problem: My initial subscribe and add/remove methods used Set<Listener> and iterated over them directly:
// Attempted:
const listeners = new Set<() => void>();
// ...
for (const l of listeners) { // TS2802: For-of loops on the Set type require the '--downlevelIteration' compiler option
l();
}
This error pops up when your TypeScript target is older (e.g., ES5 or ES2015) and you're trying to use modern iteration protocols (like for...of on Set or Map). Set was introduced in ES6, and for...of iteration over it requires a specific transpilation strategy if targeting older environments.
The Solution: Rather than changing the tsconfig for a single instance, I switched to array-based listeners for simplicity in this specific context:
// Solution:
let listeners: (() => void)[] = []; // Changed to array
// Add listener
listeners.push(listener);
// Notify listeners
listeners.forEach(listener => listener());
// Unsubscribe (filter out)
listeners = listeners.filter(l => l !== listener);
While Set offers better performance for add/delete due to its constant-time operations, for a relatively small number of listeners, an array with forEach and filter is perfectly acceptable and avoids the downlevelIteration flag.
3. Conquering Stale Closures in Async Callbacks
The Problem: This is a classic React pitfall. I initially tried to manage the enrichingId in component state and use it in onSuccess/onError callbacks:
// Attempted (and failed):
const [enrichingId, setEnrichingId] = React.useState<string | null>(null);
const { mutate: enrichNote } = useEnrichNoteMutation({
onMutate: (noteId) => {
setEnrichingId(noteId); // This updates state
ephemeralProcessStore.add({ id: noteId, /* ... */ });
},
onSuccess: () => {
// Problem: `enrichingId` here might be stale!
// It captures the value from the render *before* setEnrichingId() caused a re-render.
if (enrichingId) {
ephemeralProcessStore.remove(enrichingId);
}
setEnrichingId(null);
},
// ...
});
The issue is that onSuccess (and onError) closures capture the enrichingId state from the render at the time the mutate function was called. If setEnrichingId triggers a re-render, the onSuccess callback from the previous render might still be waiting to execute, holding onto an outdated enrichingId.
The Solution: The useEnrichNoteMutation hook (from react-query or similar) provides callback arguments that are specific to that invocation of mutate. By using onSettled (which runs after onSuccess or onError) and accessing variables.id, we ensure we're always working with the correct, non-stale id:
// Solution:
const { mutate: enrichNote } = useEnrichNoteMutation({
onSettled: (data, error, variables) => {
// `variables.id` is guaranteed to be the ID from *this specific mutation call*.
ephemeralProcessStore.remove(variables.id);
enrichingRef.current = null; // Also clear the ref
},
// ...
});
This is a crucial pattern for handling async operations in React where you need to reference data tied to the specific invocation.
Unit Testing for Reliability
To ensure the ephemeral-processes store is robust, we added 16 dedicated unit tests in tests/unit/ephemeral-processes.test.ts. These cover essential functionality:
add: Ensures processes are added correctly and deduplicated.remove: Verifies processes are removed and handles no-op removals.subscribe/unsubscribe: Tests that listeners are correctly notified and removed.multiple listeners: Confirms all active listeners are triggered.snapshot referential stability: EnsuresgetSnapshot()returns a new array reference only when the underlying data changes, which is key foruseSyncExternalStoreefficiency.
With these tests, plus our existing 139, we're at a healthy 155 passing unit tests, and the type-checker is clean.
Wrapping Up
This session was a great example of how a seemingly small UX feature can lead to interesting technical explorations. We built a flexible useSyncExternalStore-backed system for managing transient client-side state, integrated it into the UI with thoughtful feedback, and learned valuable lessons about TypeScript's useRef behavior, Set iteration, and the perennial challenge of stale closures in React's async world.
The result is a more responsive and user-friendly application, where users get immediate, clear feedback on what's happening behind the scenes. On to the next challenge – perhaps integrating this with E2E tests!
{
"thingsDone": [
"Created `src/lib/ephemeral-processes.tsx` (React context + useSyncExternalStore-backed store)",
"Updated `src/app/providers.tsx` with `EphemeralProcessProvider`",
"Updated `src/components/layout/active-processes.tsx` for sidebar integration (Sparkles, cyan, pulsing progress)",
"Hooked `NotesTab` enrichment into ephemeral process context with stale-closure-safe cleanup",
"Exported `createStore()` for testing purposes",
"Created `tests/unit/ephemeral-processes.test.ts` (16 unit tests for store functionality)"
],
"pains": [
"TS2540: `useRef<StoreType>(null)` with `current` being read-only",
"TS2802: `Set` iteration requiring `--downlevelIteration` compiler option",
"Stale closure issues with `enrichingId` state in `onSuccess`/`onError` callbacks"
],
"successes": [
"Used `useState(() => createStore())` for one-time store initialization",
"Switched to array-based listeners with `.forEach()` and `.filter()` for `Set` iteration workaround",
"Implemented per-invocation `onSettled` with locally-scoped variables to prevent stale closures",
"Achieved real-time visual feedback for client-side processes",
"Ensured comprehensive unit test coverage for the new store"
],
"techStack": [
"React",
"TypeScript",
"useSyncExternalStore",
"React Context",
"Jest",
"React Query"
]
}