Bringing Life to the Sidebar: Real-time Progress with `useSyncExternalStore` and Tackling React's Quirks
Dive into how we implemented real-time, ephemeral process progress in our React sidebar, leveraging `useSyncExternalStore` and navigating common pitfalls like stale closures and TypeScript challenges.
Building engaging user interfaces often means giving users immediate feedback. There's nothing worse than clicking a button and wondering if anything is happening. This week, our mission was to bring that crucial "something is happening" feedback directly into our application's sidebar, specifically for client-side, ephemeral processes like enriching a note.
The goal was clear: when a user triggers a long-running but client-side process, a visual indicator should appear in the sidebar, showing progress and allowing for cancellation. By the end of the session, we not only achieved this but also fortified our new state management layer with a robust suite of unit tests.
The Journey: Building Real-time Ephemeral Process Tracking
Our solution centered around creating a dedicated, lightweight store for these "ephemeral processes." These are tasks that start and end within the client's session, don't require server-side persistence for their progress, but need to be visible across different parts of the UI.
Here's how we pieced it together:
-
Introducing
ephemeral-processes.tsx: This new file became the heart of our ephemeral process management. We leveraged React'suseSyncExternalStorehook, which is perfectly suited for subscribing to external, mutable data sources. This hook ensures that our React components re-render efficiently only when the store's state changes, while keeping the store logic separate from the component tree. It provides acreateStorefunction that encapsulates the logic for adding, removing, and notifying listeners.typescript// Simplified structure of ephemeral-processes.tsx import { useSyncExternalStore } from 'react'; type EphemeralProcess = { id: string; label: string; progress: number; // 0-100 // ... other process details }; function createStore() { let processes: EphemeralProcess[] = []; const listeners = new Set<() => void>(); // Or Array, see 'Pains' section return { addProcess(process: EphemeralProcess) { /* ... */ }, removeProcess(id: string) { /* ... */ }, getSnapshot() { return processes; }, subscribe(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener); }, // ... other store methods }; } export const EphemeralProcessContext = createContext<ReturnType<typeof createStore> | null>(null); export function EphemeralProcessProvider({ children }: { children: ReactNode }) { const store = useState(() => createStore())[0]; // See 'Pains' section for useRef alternative return ( <EphemeralProcessContext.Provider value={store}> {children} </EphemeralProcessContext.Provider> ); } export function useEphemeralProcesses() { const store = useContext(EphemeralProcessContext); if (!store) throw new Error("useEphemeralProcesses must be used within an EphemeralProcessProvider"); return useSyncExternalStore(store.subscribe, store.getSnapshot); } -
Global Availability via
providers.tsx: To make ourEphemeralProcessProvideravailable throughout the application, we simply added it to our rootproviders.tsxfile, wrapping our main application tree. This ensures any component can hook into the ephemeral process store. -
Visualizing Progress in
active-processes.tsx: This component, residing in our layout's sidebar, is where the magic happens visually. We integrated the ephemeral processes into the sidebar, giving them a distinct look: aSparklesicon, a vibrant cyan color, and a pulsing progress bar. We also extracted atypeBarColorsmap to manage visual consistency for different process types. -
Hooking into Enrichment in
page.tsx: TheNotesTabcomponent, responsible for triggering note enrichment, was updated to interact with our new store. When an enrichment process starts, it adds an entry to theEphemeralProcessProvider. Crucially, we implemented a robust cleanup mechanism using per-invocationonSettledcallbacks on our.mutate()calls, a cancel button, and anonUnmounteffect via a ref. This ensures that even if a user navigates away or cancels, the ephemeral process is properly removed from the sidebar. -
Robustness Through Testing: To ensure the reliability of our custom store, we exported
createStore()and wrote 16 dedicated unit tests intests/unit/ephemeral-processes.test.ts. These tests cover critical scenarios: adding and removing processes, deduplication, no-op removals, subscribe/unsubscribe functionality, handling multiple listeners, and ensuring snapshot referential stability. With these new tests, our project now boasts 155 passing unit tests, and a clean typecheck.
Challenges and Lessons Learned
No development session is without its speed bumps. These are the moments where we learn the most, transforming "pain" into valuable insights.
1. useRef's Read-Only current and TypeScript's Strictness
The Problem:
My initial instinct for creating a singleton store instance within a React component was to use useRef:
// Initial (failed) attempt
const storeRef = useRef<StoreType>(null);
if (!storeRef.current) {
storeRef.current = createStore(); // TS2540: Cannot assign to 'current' because it is a read-only property.
}
TypeScript correctly pointed out that storeRef.current is read-only when initialized with null. This is because useRef infers a type that includes null for current, and subsequent assignments don't narrow the type or change its read-only status in that specific context.
The Solution:
The elegant workaround involved useState with a functional initializer. This pattern ensures the createStore() function is called only once during the initial render, effectively giving us our singleton instance.
const [store] = useState(() => createStore()); // Only called once
Lesson Learned: useState with a lazy initializer (() => initialValue) is often a more idiomatic and type-safe way to create memoized, single-instance values within a component's lifecycle compared to useRef for mutable state that isn't directly tied to a DOM element.
2. Set Iteration and downlevelIteration
The Problem:
When iterating over the Set of listeners for our store, I initially wrote:
// Initial (failed) attempt
for (const l of listeners) {
l(); // TS2802: 'for-of' statement requires a 'Symbol.iterator' iterator.
// The compiler will provide one if the '--downlevelIteration' flag is provided.
}
Our TypeScript configuration, understandably, didn't have --downlevelIteration enabled. This flag is necessary when targeting older JavaScript environments (like ES5) that don't natively support for...of loops for all iterable types, including Set.
The Solution: Instead of modifying the global TypeScript configuration for this specific case, we opted for an array-based approach for listeners, which is universally supported and equally effective:
// Workaround
const listeners: (() => void)[] = []; // Using an array
// ...
listeners.forEach(l => l()); // For notifying
// For unsubscribe:
listeners = listeners.filter(l => l !== listener);
Lesson Learned: While Set offers unique value semantics, being mindful of your target JavaScript environment and TypeScript compiler options is crucial. Sometimes, a slightly less "modern" but universally compatible pattern (like array-based iteration) is the pragmatic choice to avoid configuration overhead or runtime issues.
3. The Classic Stale Closure Trap
The Problem:
We needed to remove a process from the sidebar once its enrichment was complete. My initial thought was to capture the enrichingId (the ID of the note being enriched) from the component's state within the onSuccess or onError callbacks of our mutation.
// Initial (failed) attempt in component
const [enrichingId, setEnrichingId] = useState<string | null>(null);
const { mutate } = useMutation(enrichNote, {
onSuccess: () => {
// This 'enrichingId' could be stale if a new enrichment started before this one finished
if (enrichingId) {
ephemeralStore.removeProcess(enrichingId);
setEnrichingId(null);
}
},
// ...
});
// Inside a click handler:
mutate(note.id);
setEnrichingId(note.id); // This update won't be seen by the onSuccess above immediately.
The problem here is a classic React stale closure: the onSuccess callback would capture the value of enrichingId from the render cycle before setEnrichingId(note.id) had updated the state. If another enrichment was triggered quickly, the wrong ID might be removed, or no ID at all.
The Solution:
The robust solution was to leverage the onSettled callback provided by our mutation library (e.g., TanStack Query). This callback receives the original variables passed to mutate as an argument, ensuring that the note.id is always the correct, per-invocation value.
// Workaround: Stale-closure-safe cleanup
const { mutate } = useMutation(enrichNote, {
onSettled: (data, error, noteId) => { // noteId is the actual ID passed to this specific mutate call
ephemeralStore.removeProcess(noteId);
},
// ...
});
Lesson Learned: When dealing with asynchronous operations and callbacks in React, always be wary of stale closures. If a callback needs access to a value specific to its invocation, ensure that value is passed directly to the callback or captured in its local scope, rather than relying on component state that might have changed by the time the callback executes.
Looking Ahead
With the core functionality and unit tests in place, our next steps involve:
- E2E Testing: Building an end-to-end test to simulate the user journey: triggering enrichment, verifying the sidebar shows progress, and confirming its disappearance upon completion.
- Shared Types: Considering extracting
ProcessTypeto a shared types file for better organization and reusability across the codebase.
This session was a fantastic dive into building responsive UI feedback, solidifying our state management with useSyncExternalStore, and navigating some common but crucial frontend development challenges. Every "pain" point became a valuable lesson, making our codebase stronger and our understanding deeper.
{
"thingsDone": [
"Implemented real-time ephemeral process tracking in sidebar",
"Created `useSyncExternalStore`-backed store for ephemeral processes",
"Integrated `EphemeralProcessProvider` into application provider tree",
"Updated sidebar component to display ephemeral processes with visual cues",
"Hooked note enrichment into ephemeral process context with stale-closure-safe cleanup",
"Exported store creator for testing purposes",
"Wrote 16 new unit tests for ephemeral process store"
],