nyxcore-systems
8 min read

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.

ReactTypeScriptState ManagementuseSyncExternalStoreUI/UXFrontendSoftware DevelopmentNext.js

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:

  1. A dedicated client-side state management solution for these ephemeral processes.
  2. Integrating this state into our existing UI components.
  3. 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:

typescript
// 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.

typescript
// 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 enrichment type, complete with a sparkling icon and a distinct cyan color.
  • DRY Refactor: Extracted inline color classes into a typeBarColors map for better maintainability.
  • Unified Interface: Created a DisplayProcess interface, which cleverly merges our existing server-side processes with the new EphemeralProcess type. 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:

  1. On Start: When a user initiates enrichment, we call ephemeral.add() to register the process with our store, making it appear in the sidebar.
  2. On Completion/Error: This was a critical point for ensuring robustness. Instead of relying on onSuccess or onError callbacks directly, we leveraged the onSettled callback of our useMutation hook. This ensures ephemeral.remove() is always called, regardless of whether the mutation succeeded or failed.
  3. Cancellation: If the user explicitly cancels the enrichment, we also call ephemeral.remove().
  4. Unmount Cleanup: We added a useEffect teardown with a useRef to ensure that if a component unmounts while an enrichment is still active, the corresponding ephemeral process is removed from the sidebar.
typescript
// 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 assigning storeRef.current = createStore() in a useEffect.
  • The Problem: TypeScript (especially with React 18+ stricter types) threw TS2540: Cannot assign to 'current' because it is a read-only property. when useRef's initial value was null. This is because if you initialize useRef with null, TypeScript infers current could be null or StoreType, and assigning to current directly after initialization (outside of the initializer) is considered unsafe if the initial type allows null.
  • The Workaround: The elegant solution was useState(() => createStore()). useState with 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 our listeners collection, thinking it would naturally handle unique listeners. Iterating with for (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. While Set offers uniqueness guarantees, for a simple observer pattern, an array with careful add and remove logic 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 enrichingId in component state (useState) and then call ephemeral.remove(enrichingId) in the onSuccess or onError callbacks of useMutation.
  • The Problem: This led to a classic React "stale closure" issue. The enrichingId captured by onSuccess/onError was from the render before setEnrichingId() updated the state. Consequently, it was often null or an outdated ID.
  • The Workaround: The robust solution was to move the ephemeral.remove() call to the per-invocation onSettled callback of the mutate() call itself. The onSettled callback receives the original variables and context passed to mutate, which are fresh for that specific invocation. By passing the note.id (or a derived processId) directly in the onMutate context, onSettled could reliably access the correct ID to remove. This highlights the power and importance of onSettled for 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 ProcessType to 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.