Leveling Up Our UI: Tracking Ephemeral Processes in React (and the Lessons Learned)
Ever wondered how to show real-time progress for background tasks in a React app without a full-blown state management library? Join me as I recount the journey of adding an enrichment progress indicator to our sidebar, complete with the technical hurdles and elegant solutions.
Building a responsive user interface often means giving users real-time feedback, especially for operations that take more than a blink of an eye. Recently, we tackled just this challenge: adding a live progress indicator to our sidebar for ongoing 'enrichment' processes. It was a fascinating dive into React's capabilities, TypeScript's strictness, and the ever-present dance with state management.
This post isn't just about the 'what' we built, but the 'how' and, more importantly, the 'pain' points and the invaluable lessons learned along the way.
The Goal: Bringing Clarity to Background Processes
Our application performs various background tasks, like "enriching" notes with additional data. Previously, these operations were a bit of a black box – you'd click a button, and eventually, the data would update. Not ideal for user experience.
The goal was clear:
- Show "Enriching..." in the sidebar's active processes panel as soon as an enrichment task starts.
- Make it visually distinct with a unique icon and color.
- Indicate ongoing activity with a pulsing animation.
- Automatically disappear when the process completes or fails.
- Crucially, this needed to be client-side only for ephemeral tasks that don't need persistent server-side tracking in the active processes panel.
Under the Hood: Building a Real-time Process Store
To achieve this, we needed a way to manage client-side, ephemeral processes that could be accessed and updated from anywhere in the component tree, and crucially, notify components of changes efficiently.
Introducing EphemeralProcessContext with useSyncExternalStore
This was the perfect scenario for a combination of React Context and the useSyncExternalStore hook. useSyncExternalStore is ideal for integrating with external mutable stores (like a simple singleton JavaScript object) and ensures that components re-render only when the external store actually changes, and in a way that avoids tearing.
Here’s a simplified look at our createStore function and how it sets up the EphemeralProcessProvider:
// src/lib/ephemeral-processes.tsx
import { useSyncExternalStore, createContext, useContext } from 'react';
type EphemeralProcess = {
id: string;
type: 'enrichment'; // extensible
message: string;
};
// A simple external mutable store
const createEphemeralProcessStore = () => {
let processes = new Map<string, EphemeralProcess>();
let listeners: (() => void)[] = [];
return {
add: (id: string, type: EphemeralProcess['type'], message: string) => {
if (!processes.has(id)) { // Prevent duplicates
processes.set(id, { id, type, message });
listeners.forEach(listener => listener());
}
},
remove: (id: string) => {
if (processes.delete(id)) {
listeners.forEach(listener => listener());
}
},
subscribe: (listener: () => void) => {
listeners = [...listeners, listener];
return () => {
listeners = listeners.filter(l => l !== listener);
};
},
getSnapshot: () => Array.from(processes.values()),
};
};
// We use useState with a lazy initializer to ensure the store is a stable singleton.
// More on this in the "Lessons Learned" section!
const EphemeralProcessContext = createContext<ReturnType<typeof createEphemeralProcessStore> | null>(null);
export const EphemeralProcessProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [store] = useState(() => createEphemeralProcessStore()); // Lazy init!
return (
<EphemeralProcessContext.Provider value={store}>
{children}
</EphemeralProcessContext.Provider>
);
};
export const useEphemeralProcesses = () => {
const store = useContext(EphemeralProcessContext);
if (!store) {
throw new Error('useEphemeralProcesses must be used within an EphemeralProcessProvider');
}
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
return {
add: store.add,
remove: store.remove,
processes: snapshot,
};
};
This EphemeralProcessProvider was then seamlessly integrated into our main src/app/providers.tsx file, wrapping our entire application with this new capability.
UI Integration: Making it Sparkle
With the store in place, the next step was to make the UI reflect these processes.
In src/components/layout/active-processes.tsx:
- We introduced a new
enrichmenttype, complete with aSparklesicon and a distinct cyan color. - A
DisplayProcessinterface was created to gracefully merge processes tracked by the server and our new ephemeral client-side processes. This ensures a unified display in the sidebar. - Ephemeral processes now display a subtle pulsing progress bar and, importantly, do not have a cancel button, distinguishing them from server-tracked, cancelable tasks.
- A small DRY refactor extracted the inline color classes into a
typeBarColorsmap for better maintainability.
Finally, in src/app/(dashboard)/dashboard/projects/[id]/page.tsx (specifically, the NotesTab component where enrichment is triggered):
- We hooked into our new
useEphemeralProcesses()context. - When an enrichment starts,
ephemeral.add()is called. - When it completes (or fails),
ephemeral.remove()is called via a per-invocationonSettledcallback on ouruseMutationhook. This ensures the process is removed precisely when the server interaction concludes. - We also added cleanup logic using
enrichingIdRefand auseEffectteardown to ensure processes are removed if the component unmounts while an enrichment is active.
The "Aha!" Moments (Lessons Learned)
No development session is complete without hitting a few snags. These "pain log" entries are often where the most valuable learning happens.
1. useRef vs. useState for Lazy Initialization
The Problem:
My initial instinct for creating a stable singleton store was to use useRef:
const storeRef = useRef<ReturnType<typeof createEphemeralProcessStore>>(null);
if (!storeRef.current) {
storeRef.current = createEphemeralProcessStore(); // TS2540!
}
// Then use storeRef.current
This immediately hit a TS2540: Cannot assign to 'current' because it is a read-only property. error. React 18+ with strict TypeScript types makes the current property of a MutableRefObject read-only if its initial value is null, to prevent common mistakes where the ref might not be assigned before use.
The Solution:
The elegant workaround is useState with a lazy initializer. This ensures the createStore function is only called once, on the initial render, and the returned store object remains stable across re-renders:
const [store] = useState(() => createEphemeralProcessStore());
// 'store' is now a stable singleton, initialized once.
Takeaway: For stable, singleton objects within a component's lifecycle, useState with a lazy initializer (useState(() => ...) is often a cleaner and safer choice than useRef when TypeScript's strictness is involved, especially if the ref starts as null.
2. TypeScript's Iteration Quirks with Set
The Problem:
My initial implementation for listeners in createEphemeralProcessStore used a Set<Listener> for automatic deduplication and easy removal. However, iterating over it with a for...of loop:
// Initial attempt in createEphemeralProcessStore
let listeners = new Set<Listener>();
// ...
for (const l of listeners) { l(); } // TS2802!
This triggered TS2802: 'for-of' statements are only allowed in an ES3 or ES5 target when the '--downlevelIteration' flag is enabled. Our project's TypeScript configuration didn't have this flag enabled, and enabling it globally felt like overkill for this specific use case.
The Solution:
Switching to a plain array (let listeners: Listener[] = []) and using .forEach() for iteration, along with filter for unsubscribing, resolved the issue cleanly:
// Updated in createEphemeralProcessStore
let listeners: Listener[] = [];
// ...
listeners.forEach(listener => listener()); // Works fine
// For unsubscribe:
listeners = listeners.filter(l => l !== listener);
Takeaway: Be mindful of your tsconfig.json's target and lib settings. While Set is great, sometimes an array with simple iteration methods is more compatible with existing project configurations and avoids unnecessary flag enabling.
3. Conquering Stale Closures in useMutation Callbacks
The Problem:
When triggering an enrichment, I needed to track which specific note was being enriched to display its ID in the sidebar. My initial approach involved storing the enrichingId in component state (useState) and then attempting to remove it in the onSuccess or onError callbacks of useMutation:
// Inside NotesTab component (simplified)
const [enrichingId, setEnrichingId] = useState<string | null>(null);
const enrichNoteMutation = useMutation(someApiCall, {
onSuccess: () => {
// Problem: enrichingId here is captured from the render BEFORE setEnrichingId was called
// So it might be null or an old ID.
if (enrichingId) ephemeral.remove(enrichingId);
setEnrichingId(null);
},
// ...
});
// Later, when calling mutate:
enrichNoteMutation.mutate({ id: note.id, ... });
setEnrichingId(note.id); // This causes a re-render.
This led to a classic stale closure bug. The onSuccess callback would capture the value of enrichingId from the render cycle before setEnrichingId(note.id) had been called and a re-render occurred. So, when the mutation completed, enrichingId inside onSuccess was often null or an outdated ID.
The Solution:
The key insight was to leverage the per-invocation callback options available with mutate(). By moving the ephemeral.remove() call to the onSettled callback directly on the mutate() call, the note.id (or whatever unique identifier) is available in the current, non-stale scope:
// Inside NotesTab component (simplified)
const { add: addEphemeralProcess, remove: removeEphemeralProcess } = useEphemeralProcesses();
const enrichNoteMutation = useMutation(someApiCall); // No global onSuccess/onError here
// When calling mutate:
const startEnrichment = (noteId: string) => {
addEphemeralProcess(noteId, 'enrichment', `Enriching Note ${noteId}...`);
enrichNoteMutation.mutate(
{ id: noteId, /* other data */ },
{
onSettled: () => {
// 'noteId' is available in this scope, it's not stale!
removeEphemeralProcess(noteId);
},
// You could also add onSuccess/onError here if needed,
// but onSettled covers both completion and failure for removal.
}
);
};
Takeaway: When dealing with useMutation or similar async operations where you need to reference specific data related to that particular invocation, prefer per-invocation callbacks (mutate(variables, { onSuccess, onError, onSettled })) over global useMutation options to avoid stale closure issues. The data will be captured correctly for that specific call.
Wrapping Up & What's Next
This session was a great example of how a seemingly small UI feature can lead to a robust client-side state management solution and surface some critical learning opportunities. The enrichment progress indicator is now live, providing a much-improved user experience.
Of course, the work is never truly done! Here are our immediate next steps:
- Unit test
createStore(): Ensure our ephemeral process store is rock solid (add/remove/subscribe/dedup/no-op remove). - E2E test: Verify the entire flow: trigger enrichment → sidebar shows "Enriching with wisdom" → disappears on completion.
- Address other known issues: The code-analysis page still needs
projectIdonReportGeneratorModal. - Update documentation: Add new compliance report export docs to
docs/06-workflow-intelligence.md. - Refactor
ProcessType: Consider extractingProcessTypeto a shared types file for better organization.
It's these iterative steps, combined with a willingness to tackle and learn from "pain points," that really drive a project forward. Happy coding!
{"thingsDone":["Created EphemeralProcessProvider with useSyncExternalStore","Updated active-processes.tsx for enrichment type UI","Integrated ephemeral processes into NotesTab","Implemented add/remove lifecycle for ephemeral processes"],"pains":["TS2540 with useRef for lazy initialization","TS2802 with Set iteration requiring downlevelIteration","Stale closures in useMutation callbacks"],"successes":["Achieved real-time client-side progress indicator","Utilized useSyncExternalStore effectively for external store integration","Learned best practices for useState lazy initialization","Resolved stale closure issues with per-invocation useMutation callbacks"],"techStack":["React","TypeScript","Next.js","useSyncExternalStore","Context API","React Query (useMutation)"]}