Enriching the Experience: Bringing Real-time Progress to Life with `useSyncExternalStore`
Dive into how we built a reactive, client-side store with `useSyncExternalStore` to deliver real-time progress updates, tackling TypeScript quirks and stale closures along the way.
User experience is paramount in modern applications. There's nothing worse than clicking a button and wondering if anything is happening, or how long it will take. This past development session was all about dispelling that uncertainty, specifically for our "enrichment" processes. These are powerful, client-side operations that can take a moment, and our users deserve to see their progress unfold in real-time.
Our mission? To bring these ephemeral (short-lived) client-side processes, like enriching a note with AI-powered wisdom, out of the shadows and into a vibrant, real-time sidebar display. We also focused heavily on ensuring the robustness of our new state management solution with comprehensive unit tests.
The Goal: Real-time Feedback, Crystal Clear
Imagine a user enriching a note. Before this session, they'd click "Enrich," and... wait. Now, the goal was to instantly show a "Sparkles" icon in the sidebar, a pulsing cyan progress bar, and a message like "Enriching with wisdom." Once complete, or if an error occurred, the process would gracefully disappear. This required a robust, reactive client-side store.
Building the Brains: An Ephemeral Process Store
The core of our solution was a new React context and store, src/lib/ephemeral-processes.tsx. We opted for useSyncExternalStore – a React hook purpose-built for integrating with external, mutable state sources. It's perfect for scenarios where you have a store outside of React's direct control but need components to re-render efficiently when that store changes.
Here's a simplified look at the store's responsibilities:
addProcess(id, type, message): To add a new process to the active list.removeProcess(id): To remove a completed or canceled process.subscribe(listener): To allow React components to listen for changes.getSnapshot(): To provide the current state of processes to subscribers.
With the store in place, we wrapped our application in an EphemeralProcessProvider within src/app/providers.tsx. This made the store accessible throughout our component tree.
Next, the src/components/layout/active-processes.tsx component (our sidebar) was updated to consume this new context. It now listens for changes in active ephemeral processes and beautifully renders them with our new "Sparkles" icon, vibrant cyan color, and a pulsing progress bar. We even extracted a typeBarColors map for easy theming!
Finally, the NotesTab component in src/app/(dashboard)/dashboard/projects/[id]/page.tsx was hooked up. When an enrichment process starts, it adds itself to the EphemeralProcessProvider. Crucially, we implemented robust cleanup:
- On process completion (
onSettled). - If the user cancels the operation.
- If the component unmounts before completion (via a
useRef-based cleanup mechanism).
Lessons Learned: Navigating the Development Minefield
Even with a clear goal, development often presents interesting challenges. Here are a few "aha!" moments and workarounds we encountered:
1. The Case of the Read-Only useRef and the useState Rescue
The Problem: We initially tried to initialize our store within a useRef like this:
const storeRef = useRef<StoreType>(null);
// ... later in a useEffect or similar ...
if (!storeRef.current) {
storeRef.current = createStore(); // TypeScript error TS2540!
}
TypeScript rightly flagged storeRef.current as read-only because useRef with an initial null infers current to be T | null. When you assign to it, TS thinks you're trying to reassign the type of current, not just its value.
The Solution: The elegant workaround was to use useState with a lazy initializer:
const [store] = useState(() => createStore());
This ensures createStore() is called only once on initial render, and store remains a stable reference to our store instance throughout the component's lifecycle, perfectly typed.
2. TypeScript's Strict Iteration Rules: Embracing Arrays
The Problem: When managing our Set<Listener> for subscriptions, we initially tried to iterate directly:
for (const listener of listeners) {
listener(snapshot);
}
TypeScript threw TS2802, indicating that Set iteration requires the --downlevelIteration compiler option. While we could enable this, it's often better to avoid global compiler flags if a more idiomatic solution exists.
The Solution: We switched to an array-based approach for listeners, leveraging familiar array methods:
// For notifying listeners
listeners.forEach(listener => listener(snapshot));
// For unsubscribing
listeners = listeners.filter(l => l !== listenerToRemove);
This approach is clean, performant enough for our needs, and plays nicely with TypeScript's default settings.
3. Taming Stale Closures: The Power of Per-Invocation Callbacks
The Problem: Our enrichment logic uses a mutate() function (from a library like React Query or SWR). We initially tried to update an enrichingId state variable within onSuccess or onError callbacks. The issue? These callbacks captured the enrichingId from the render cycle before setEnrichingId() was called, leading to stale closure problems where the wrong ID was being cleaned up or status wasn't updating correctly.
The Solution: The key was to pass the note.id directly into the onSettled callback of the mutate() invocation. onSettled always runs, regardless of success or failure, and crucially, note.id is in scope for that specific invocation of mutate():
// Inside the component where mutate is called
const { mutate } = useMutation(enrichNote, {
onSettled: (data, error, variables) => {
// variables.id will be the correct note.id for THIS specific mutation call
ephemeralProcessStore.removeProcess(variables.id);
},
});
// When triggering the mutation
mutate({ id: note.id, content: note.content });
This pattern ensures that the cleanup logic always refers to the correct process ID, preventing race conditions and ensuring accurate UI updates. We also added unmount cleanup via a useRef to catch cases where a user navigates away mid-process.
Ensuring Reliability: Comprehensive Unit Testing
No new store is complete without thorough testing. We added 16 new unit tests to tests/unit/ephemeral-processes.test.ts, covering critical aspects:
- Adding and removing processes.
- Deduplication of processes (ensuring only one active process per ID).
- No-op removal (trying to remove a non-existent process).
- Correct
subscribeandunsubscribebehavior. - Handling multiple listeners.
- Ensuring snapshot referential stability (important for
useSyncExternalStoreefficiency).
With these new tests, our total count now stands at 155 passing unit tests, and the type checker is clean. Confidence in our new system is high!
What's Next?
With the core functionality in place, our immediate next steps include:
- E2E Testing: Building an end-to-end test to simulate triggering enrichment and verifying the sidebar's behavior.
- Documentation: Updating our internal workflow intelligence documentation to reflect these new features.
- Refinement: Considering minor improvements like extracting
ProcessTypeto a shared types file.
This session was a fantastic step forward in making our application more responsive and user-friendly. By leveraging powerful React hooks and tackling common frontend challenges head-on, we've enriched not just our notes, but the entire user experience!