The Pulse of Progress: Building Live UI Feedback for Client-Side Operations
Dive into how we implemented a dynamic progress indicator for client-side operations, tackling common React and TypeScript challenges along the way to enhance user experience.
In modern web applications, user experience hinges on clear, immediate feedback. Nobody likes staring at a static screen wondering if their action registered or if the system is simply frozen. That's why adding real-time progress indicators for background tasks isn't just a nice-to-have; it's essential.
Recently, our goal was to enhance our application's sidebar with a live progress indicator for an "enrichment" process. This particular process runs entirely on the client-side, making it an ephemeral client-side operation – short-lived, UI-focused, and not tied to a long-running server-side job. This post details our journey, the technical decisions we made, and the valuable lessons we learned along the way.
The Challenge: Visualizing Ephemeral Processes
Our application already had a robust system for displaying long-running server-side processes in the sidebar. The challenge was extending this to client-side tasks like "enrichment," which might involve complex computations or API calls that don't immediately return. Users needed to see "Enriching with wisdom..." with a visual cue, and then have it disappear cleanly upon completion or cancellation.
This required:
- A dedicated client-side state management solution for these ephemeral processes.
- Integrating this state into our existing UI components.
- Handling the lifecycle of these processes (add, remove, update).
Building the Ephemeral Process Store
Our first step was to create a lightweight, performant store specifically for these client-side processes. Given we're in a React environment, and aiming for optimal performance with external stores, useSyncExternalStore was the natural choice. This React 18+ hook is designed precisely for integrating with external, mutable data sources while ensuring React's concurrent rendering capabilities aren't compromised.
We created src/lib/ephemeral-processes.tsx to house our store:
// Simplified structure for ephemeral-processes.tsx
interface EphemeralProcess {
id: string;
type: 'enrichment'; // Could be extended for other types
message: string;
}
interface EphemeralProcessStore {
add: (process: EphemeralProcess) => void;
remove: (id: string) => void;
getSnapshot: () => EphemeralProcess[];
subscribe: (listener: () => void) => () => void;
}
function createEphemeralProcessStore(): EphemeralProcessStore {
let processes: EphemeralProcess[] = [];
let listeners: (() => void)[] = [];
return {
add: (process) => {
processes = [...processes, process];
listeners.forEach(l => l());
},
remove: (id) => {
processes = processes.filter(p => p.id !== id);
listeners.forEach(l => l());
},
getSnapshot: () => processes,
subscribe: (listener) => {
listeners = [...listeners, listener];
return () => {
listeners = listeners.filter(l => l !== listener);
};
},
};
}
// And then a React context and a custom hook to use it
// const EphemeralProcessContext = React.createContext<EphemeralProcessStore | null>(null);
// export const useEphemeralProcesses = () => useContext(EphemeralProcessContext)!;
This store provides a simple API to add, remove, getSnapshot, and subscribe to changes.
Integrating with the React Tree
With the store defined, the next step was to make it available throughout our application. We wrapped our entire application with an EphemeralProcessProvider in src/app/providers.tsx. This provider initializes our createEphemeralProcessStore() once and makes it accessible via a custom useEphemeralProcesses() hook.
// src/app/providers.tsx (simplified)
import React, { useState } from 'react';
import { createEphemeralProcessStore, EphemeralProcessProvider } from '@/lib/ephemeral-processes';
export function Providers({ children }: { children: React.ReactNode }) {
// Key insight: Using useState with a lazy initializer for stable store instance
const [ephemeralStore] = useState(() => createEphemeralProcessStore());
return (
<EphemeralProcessProvider value={ephemeralStore}>
{children}
</EphemeralProcessProvider>
);
}
The Visual Layer: Updating the Sidebar
The core UI component for displaying active processes is src/components/layout/active-processes.tsx. We made several updates here:
- New Process Type: Introduced an
enrichmenttype, complete with a sparkling icon and a distinct cyan color. - DRY Refactor: Extracted inline color classes into a
typeBarColorsmap for better maintainability. - Unified Interface: Created a
DisplayProcessinterface, which cleverly merges our existing server-side processes with the newEphemeralProcesstype. This allowed the sidebar component to render both seamlessly. - Visual Cues: Ephemeral processes were designed to show a pulsing progress bar, indicating ongoing activity, but crucially, without a cancel button, as these are typically short-lived and not directly user-cancellable from the sidebar.
Hooking into the Enrichment Flow
The actual enrichment process is triggered from our NotesTab component within src/app/(dashboard)/dashboard/projects/[id]/page.tsx. This is where the lifecycle management of ephemeral processes happens:
- On Start: When a user initiates enrichment, we call
ephemeral.add()to register the process with our store, making it appear in the sidebar. - On Completion/Error: This was a critical point for ensuring robustness. Instead of relying on
onSuccessoronErrorcallbacks directly, we leveraged theonSettledcallback of ouruseMutationhook. This ensuresephemeral.remove()is always called, regardless of whether the mutation succeeded or failed. - Cancellation: If the user explicitly cancels the enrichment, we also call
ephemeral.remove(). - Unmount Cleanup: We added a
useEffectteardown with auseRefto ensure that if a component unmounts while an enrichment is still active, the corresponding ephemeral process is removed from the sidebar.
// Simplified integration in NotesTab
import { useEphemeralProcesses } from '@/lib/ephemeral-processes';
import { useMutation } from '@tanstack/react-query'; // Example mutation library
import React, { useRef, useEffect } from 'react';
function NotesTab({ noteId }: { noteId: string }) {
const ephemeral = useEphemeralProcesses();
const enrichingIdRef = useRef<string | null>(null);
const { mutate: enrichNote } = useMutation({
mutationFn: async (id: string) => {
// Simulate API call
return new Promise(resolve => setTimeout(resolve, 2000));
},
onMutate: (id) => {
const processId = `enrich-${id}`;
ephemeral.add({ id: processId, type: 'enrichment', message: 'Enriching with wisdom...' });
enrichingIdRef.current = processId; // Store for cleanup
return { processId };
},
onSettled: (data, error, variables, context) => {
// CRITICAL: Use onSettled to ensure removal regardless of success/failure
if (context?.processId) {
ephemeral.remove(context.processId);
}
enrichingIdRef.current = null; // Clear ref
},
// onSuccess, onError can still be used for specific logic,
// but removal is handled by onSettled for robustness.
});
// Cleanup on unmount
useEffect(() => {
return () => {
if (enrichingIdRef.current) {
ephemeral.remove(enrichingIdRef.current);
}
};
}, [ephemeral]);
const handleEnrich = () => {
enrichNote(noteId);
};
return (
<button onClick={handleEnrich}>Enrich Note</button>
);
}
Lessons Learned: Navigating React and TypeScript Quirks
This session wasn't without its challenges, and tackling them provided some valuable insights:
1. useRef for Store Initialization: A TypeScript Standoff
- The Attempt: My initial thought for creating a stable, singleton store instance was
useRef<StoreType>(null)and then assigningstoreRef.current = createStore()in auseEffect. - The Problem: TypeScript (especially with React 18+ stricter types) threw
TS2540: Cannot assign to 'current' because it is a read-only property.whenuseRef's initial value wasnull. This is because if you initializeuseRefwithnull, TypeScript inferscurrentcould benullorStoreType, and assigning tocurrentdirectly after initialization (outside of the initializer) is considered unsafe if the initial type allowsnull. - The Workaround: The elegant solution was
useState(() => createStore()).useStatewith a lazy initializer (() => createStore()) ensures the function is only called once on the initial render, providing a stable, singleton instance of our store that never changes across renders. This is the idiomatic React way to create stable, complex objects that live for the component's lifetime.
2. TypeScript and Set Iteration: --downlevelIteration
- The Attempt: I initially used a
Set<Listener>for ourlistenerscollection, thinking it would naturally handle unique listeners. Iterating withfor (const l of listeners)seemed clean. - The Problem: TypeScript immediately complained with
TS2802: 'for-of' statements are only allowed in an ES3/ES5 environment when the '--downlevelIteration' flag is enabled.. Our project setup didn't have this flag enabled, and enabling it globally can sometimes have unintended consequences or increase bundle size for older target environments. - The Workaround: I switched to a simple
Listener[](array of listeners). This allowed us to use.forEach()for notifying listeners and.filter()for unsubscribing, which is perfectly performant for the small number of listeners we expect. WhileSetoffers uniqueness guarantees, for a simple observer pattern, an array with carefuladdandremovelogic works just as well without needing specific compiler flags.
3. Stale Closures in useMutation Callbacks: The onSettled Savior
- The Attempt: My first approach to removing the process was to store the
enrichingIdin component state (useState) and then callephemeral.remove(enrichingId)in theonSuccessoronErrorcallbacks ofuseMutation. - The Problem: This led to a classic React "stale closure" issue. The
enrichingIdcaptured byonSuccess/onErrorwas from the render beforesetEnrichingId()updated the state. Consequently, it was oftennullor an outdated ID. - The Workaround: The robust solution was to move the
ephemeral.remove()call to the per-invocationonSettledcallback of themutate()call itself. TheonSettledcallback receives the originalvariablesandcontextpassed tomutate, which are fresh for that specific invocation. By passing thenote.id(or a derivedprocessId) directly in theonMutatecontext,onSettledcould reliably access the correct ID to remove. This highlights the power and importance ofonSettledfor cleanup logic that must always run and requires fresh, invocation-specific data.
Looking Ahead
While the core functionality is complete and pushed, there's always room for refinement:
- Unit Tests: We'll be adding dedicated unit tests for
createEphemeralProcessStore()to ensure its add/remove/subscribe logic is bulletproof. - E2E Tests: End-to-end tests will verify the entire user flow: triggering enrichment, seeing the sidebar indicator, and its disappearance.
- Refinements: We'll consider extracting
ProcessTypeto a shared types file for better organization and type safety across the application.
Conclusion
Adding live progress indicators for client-side operations significantly boosts user experience by providing transparency and immediate feedback. By leveraging React's useSyncExternalStore for an efficient client-side state, integrating it cleanly with our UI, and carefully managing the lifecycle of these ephemeral processes, we've made our application more responsive and user-friendly. The journey also reinforced crucial lessons about React's state management patterns, TypeScript's strictness, and the nuances of callback closures in asynchronous operations. These insights will undoubtedly serve us well in future development endeavors.