nyxcore-systems
7 min read

Taming Transient Tasks: Building Real-time UI Progress for Ephemeral Processes in React

Enhancing user experience by adding real-time, client-side progress indicators for ephemeral tasks like 'enrichment,' and navigating common React/TypeScript challenges along the way.

ReactTypeScriptFrontendUI/UXState ManagementuseSyncExternalStoreLessons Learned

User experience often hinges on feedback. When a user initiates an action, especially one that might take a few moments, the silence can be deafening. Or worse, confusing. We've all been there: clicking a button, waiting, wondering if it worked, or if the browser just froze. That's why adding clear, real-time progress indicators is crucial for a smooth and satisfying UI.

Recently, our team tackled this very challenge. Our application has a feature where users can "enrich" their notes – a potentially time-consuming background operation. While we already had a system for displaying long-running server-side processes, these client-initiated "enrichment" tasks needed a more immediate, ephemeral representation. Our goal was clear: add a pulsing progress indicator to the sidebar's active processes panel, specifically for these client-side enrichment tasks.

This post will walk through how we achieved this, from setting up a dedicated client-side store to integrating it into our React components, and the valuable lessons we learned when wrestling with TypeScript and React's intricacies.

The Core Idea: A Store for Ephemeral Client-Side Processes

Our existing "active processes" panel in the sidebar was great for showing server-initiated, long-running tasks. But enrichment, while initiated by the client, often completes relatively quickly and doesn't always have a direct server-side process ID to track in the same way. We needed a lightweight, client-side mechanism to track these transient tasks.

Enter src/lib/ephemeral-processes.tsx. This file became the home for our new React context and a useSyncExternalStore-backed store. Why useSyncExternalStore? It's a fantastic hook for integrating with external, mutable stores (like our simple process queue) without triggering unnecessary re-renders across our component tree. It ensures that only components actually subscribing to changes re-render, making it highly performant.

Our createStore function provided a simple API:

  • add(id: string, type: ProcessType, message: string): To start tracking a new ephemeral process.
  • remove(id: string): To stop tracking a process.
  • subscribe(listener: () => void): To allow React components to listen for changes.
  • getSnapshot(): To provide the current state of processes to useSyncExternalStore.

Once the store was defined, integrating it into our application was straightforward. We wrapped our application with the EphemeralProcessProvider in src/app/providers.tsx, making the useEphemeralProcesses hook available throughout our component tree.

typescript
// src/lib/ephemeral-processes.tsx (simplified)
import { useSyncExternalStore, createContext, useContext } from 'react';

type EphemeralProcess = {
  id: string;
  type: 'enrichment'; // Or other ephemeral types
  message: string;
};

// ... createStore implementation with add, remove, subscribe, getSnapshot

const EphemeralProcessContext = createContext<ReturnType<typeof createStore> | null>(null);

export const EphemeralProcessProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  // We'll dive into why useState here later!
  const [store] = useState(() => createStore());
  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 {
    processes: snapshot,
    add: store.add,
    remove: store.remove,
  };
};

Bringing it to Life: UI Integration

The next step was to visually represent these processes. We updated src/components/layout/active-processes.tsx, the component responsible for rendering the sidebar panel.

Here, we introduced a new enrichment type, complete with a sparkling icon and a distinct cyan color. To keep our styles clean and maintainable, we extracted the inline color classes into a typeBarColors map – a simple but effective DRY refactor.

The active-processes.tsx component now had to display both server-side processes and our new ephemeral client-side processes. We achieved this by defining a DisplayProcess interface that could accommodate properties from both types, allowing us to render them uniformly. Ephemeral processes were given a pulsing progress bar to visually distinguish them as ongoing client-side operations, and notably, they don't have a cancel button – as they are typically short-lived and not directly cancellable by the user in the same way a long-running report generation might be.

Putting it to Work: The NotesTab Integration

With the store and UI in place, the final piece was to hook up the actual "enrichment" action. In src/app/(dashboard)/dashboard/projects/[id]/page.tsx, specifically within our NotesTab component, we integrated useEphemeralProcesses().

When a user initiates enrichment on a note, we now call ephemeral.add() right before the mutation begins, passing in the note's ID and a descriptive message like "Enriching with wisdom...".

The crucial part was ensuring ephemeral.remove() was called reliably when the enrichment completed or failed. This is where we encountered a common pitfall, leading us to a robust solution:

typescript
// src/app/(dashboard)/dashboard/projects/[id]/page.tsx (simplified for context)
const { add, remove } = useEphemeralProcesses();
const enrichingIdRef = useRef<string | null>(null); // To track the ID for cleanup

const { mutate: enrichNoteMutation } = useMutation({
  mutationFn: enrichNote, // Your actual API call
  // ... other mutation options
});

const handleEnrichNote = (noteId: string) => {
  add(noteId, 'enrichment', 'Enriching with wisdom...');
  enrichingIdRef.current = noteId; // Store the ID for unmount cleanup

  enrichNoteMutation(
    { noteId },
    {
      onSettled: () => { // This callback is called regardless of success or error
        remove(noteId); // noteId is available in this closure scope
        if (enrichingIdRef.current === noteId) {
          enrichingIdRef.current = null; // Clear if it was the one we were tracking
        }
      },
      // ... onSuccess, onError for other side effects
    }
  );
};

// Cleanup on component unmount
useEffect(() => {
  return () => {
    if (enrichingIdRef.current) {
      remove(enrichingIdRef.current);
    }
  };
}, [remove]);

This onSettled approach, combined with enrichingIdRef for unmount cleanup, proved vital for reliability.

Lessons Learned & Hard-Won Wisdom

This session wasn't without its moments of head-scratching. Here are three critical lessons we learned (or re-learned!):

1. useRef vs. useState for Lazy Singleton Initialization

The Challenge: Our initial instinct for creating a stable, singleton store instance inside the EphemeralProcessProvider was to use useRef:

typescript
// Attempt #1 (failed)
const storeRef = useRef<ReturnType<typeof createStore>>(null);
if (!storeRef.current) {
  storeRef.current = createStore(); // TypeScript error here!
}
// ... then use storeRef.current

This failed with a TypeScript error: TS2540: Cannot assign to 'current' because it is a read-only property. This is a stricter type check in React 18+ for useRef when initialized with null, preventing direct assignment within the render phase if the type includes null. While you can assert storeRef.current!, it's not the cleanest solution for a singleton.

The Solution: The idiomatic React way to create a stable, lazily initialized singleton within a component is useState with a functional initializer:

typescript
// Solution
const [store] = useState(() => createStore());

When useState is given a function, that function is only executed once during the component's initial render. The returned value then becomes the stable state for the lifetime of the component, effectively giving us a singleton instance that doesn't change on re-renders, without the useRef typing headaches.

2. Iterating Set vs. Array for Listeners

The Challenge: In our createStore implementation, we initially used a Set<Listener> to manage subscribers, thinking it would naturally handle deduplication. When iterating over the Set to notify listeners:

typescript
// Attempt #1 (failed)
let listeners = new Set<Listener>();
// ...
for (const l of listeners) { // TS2802: Set iteration requires --downlevelIteration flag
  l();
}

This triggered a TS2802 error, requiring the --downlevelIteration TypeScript flag. While this flag can be enabled, it's often a signal to check if there's a more standard or performant alternative.

The Solution: We switched to a simple Listener[] array:

typescript
// Solution
let listeners: Listener[] = [];
// ...
listeners.forEach(l => l()); // Cleaner, no special flags needed

// For unsubscribe, we used array filter:
listeners = listeners.filter(existingListener => existingListener !== listenerToRemove);

While Set offers automatic deduplication, managing listeners with an array and filtering for unsubscribe is perfectly fine and often simpler, avoiding specific compiler flags. It also makes the iteration more straightforward and universally compatible.

3. Taming Stale Closures in useMutation Callbacks

The Challenge: This is a classic React hooks problem! We initially tried to remove the process using state captured from the component's render:

typescript
// Attempt #1 (failed - stale closure)
const [enrichingId, setEnrichingId