nyxcore-systems
7 min read

Bringing Life to the Sidebar: Real-time Progress with `useSyncExternalStore` and Tackling React's Quirks

Dive into how we implemented real-time, ephemeral process progress in our React sidebar, leveraging `useSyncExternalStore` and navigating common pitfalls like stale closures and TypeScript challenges.

ReactTypeScriptState ManagementuseSyncExternalStoreUnit TestingFrontend DevelopmentLessons Learned

Building engaging user interfaces often means giving users immediate feedback. There's nothing worse than clicking a button and wondering if anything is happening. This week, our mission was to bring that crucial "something is happening" feedback directly into our application's sidebar, specifically for client-side, ephemeral processes like enriching a note.

The goal was clear: when a user triggers a long-running but client-side process, a visual indicator should appear in the sidebar, showing progress and allowing for cancellation. By the end of the session, we not only achieved this but also fortified our new state management layer with a robust suite of unit tests.

The Journey: Building Real-time Ephemeral Process Tracking

Our solution centered around creating a dedicated, lightweight store for these "ephemeral processes." These are tasks that start and end within the client's session, don't require server-side persistence for their progress, but need to be visible across different parts of the UI.

Here's how we pieced it together:

  1. Introducing ephemeral-processes.tsx: This new file became the heart of our ephemeral process management. We leveraged React's useSyncExternalStore hook, which is perfectly suited for subscribing to external, mutable data sources. This hook ensures that our React components re-render efficiently only when the store's state changes, while keeping the store logic separate from the component tree. It provides a createStore function that encapsulates the logic for adding, removing, and notifying listeners.

    typescript
    // Simplified structure of ephemeral-processes.tsx
    import { useSyncExternalStore } from 'react';
    
    type EphemeralProcess = {
      id: string;
      label: string;
      progress: number; // 0-100
      // ... other process details
    };
    
    function createStore() {
      let processes: EphemeralProcess[] = [];
      const listeners = new Set<() => void>(); // Or Array, see 'Pains' section
    
      return {
        addProcess(process: EphemeralProcess) { /* ... */ },
        removeProcess(id: string) { /* ... */ },
        getSnapshot() { return processes; },
        subscribe(listener: () => void) {
          listeners.add(listener);
          return () => listeners.delete(listener);
        },
        // ... other store methods
      };
    }
    
    export const EphemeralProcessContext = createContext<ReturnType<typeof createStore> | null>(null);
    
    export function EphemeralProcessProvider({ children }: { children: ReactNode }) {
      const store = useState(() => createStore())[0]; // See 'Pains' section for useRef alternative
      return (
        <EphemeralProcessContext.Provider value={store}>
          {children}
        </EphemeralProcessContext.Provider>
      );
    }
    
    export function useEphemeralProcesses() {
      const store = useContext(EphemeralProcessContext);
      if (!store) throw new Error("useEphemeralProcesses must be used within an EphemeralProcessProvider");
      return useSyncExternalStore(store.subscribe, store.getSnapshot);
    }
    
  2. Global Availability via providers.tsx: To make our EphemeralProcessProvider available throughout the application, we simply added it to our root providers.tsx file, wrapping our main application tree. This ensures any component can hook into the ephemeral process store.

  3. Visualizing Progress in active-processes.tsx: This component, residing in our layout's sidebar, is where the magic happens visually. We integrated the ephemeral processes into the sidebar, giving them a distinct look: a Sparkles icon, a vibrant cyan color, and a pulsing progress bar. We also extracted a typeBarColors map to manage visual consistency for different process types.

  4. Hooking into Enrichment in page.tsx: The NotesTab component, responsible for triggering note enrichment, was updated to interact with our new store. When an enrichment process starts, it adds an entry to the EphemeralProcessProvider. Crucially, we implemented a robust cleanup mechanism using per-invocation onSettled callbacks on our .mutate() calls, a cancel button, and an onUnmount effect via a ref. This ensures that even if a user navigates away or cancels, the ephemeral process is properly removed from the sidebar.

  5. Robustness Through Testing: To ensure the reliability of our custom store, we exported createStore() and wrote 16 dedicated unit tests in tests/unit/ephemeral-processes.test.ts. These tests cover critical scenarios: adding and removing processes, deduplication, no-op removals, subscribe/unsubscribe functionality, handling multiple listeners, and ensuring snapshot referential stability. With these new tests, our project now boasts 155 passing unit tests, and a clean typecheck.

Challenges and Lessons Learned

No development session is without its speed bumps. These are the moments where we learn the most, transforming "pain" into valuable insights.

1. useRef's Read-Only current and TypeScript's Strictness

The Problem: My initial instinct for creating a singleton store instance within a React component was to use useRef:

typescript
// Initial (failed) attempt
const storeRef = useRef<StoreType>(null);
if (!storeRef.current) {
  storeRef.current = createStore(); // TS2540: Cannot assign to 'current' because it is a read-only property.
}

TypeScript correctly pointed out that storeRef.current is read-only when initialized with null. This is because useRef infers a type that includes null for current, and subsequent assignments don't narrow the type or change its read-only status in that specific context.

The Solution: The elegant workaround involved useState with a functional initializer. This pattern ensures the createStore() function is called only once during the initial render, effectively giving us our singleton instance.

typescript
const [store] = useState(() => createStore()); // Only called once

Lesson Learned: useState with a lazy initializer (() => initialValue) is often a more idiomatic and type-safe way to create memoized, single-instance values within a component's lifecycle compared to useRef for mutable state that isn't directly tied to a DOM element.

2. Set Iteration and downlevelIteration

The Problem: When iterating over the Set of listeners for our store, I initially wrote:

typescript
// Initial (failed) attempt
for (const l of listeners) {
  l(); // TS2802: 'for-of' statement requires a 'Symbol.iterator' iterator.
       // The compiler will provide one if the '--downlevelIteration' flag is provided.
}

Our TypeScript configuration, understandably, didn't have --downlevelIteration enabled. This flag is necessary when targeting older JavaScript environments (like ES5) that don't natively support for...of loops for all iterable types, including Set.

The Solution: Instead of modifying the global TypeScript configuration for this specific case, we opted for an array-based approach for listeners, which is universally supported and equally effective:

typescript
// Workaround
const listeners: (() => void)[] = []; // Using an array
// ...
listeners.forEach(l => l()); // For notifying
// For unsubscribe:
listeners = listeners.filter(l => l !== listener);

Lesson Learned: While Set offers unique value semantics, being mindful of your target JavaScript environment and TypeScript compiler options is crucial. Sometimes, a slightly less "modern" but universally compatible pattern (like array-based iteration) is the pragmatic choice to avoid configuration overhead or runtime issues.

3. The Classic Stale Closure Trap

The Problem: We needed to remove a process from the sidebar once its enrichment was complete. My initial thought was to capture the enrichingId (the ID of the note being enriched) from the component's state within the onSuccess or onError callbacks of our mutation.

typescript
// Initial (failed) attempt in component
const [enrichingId, setEnrichingId] = useState<string | null>(null);

const { mutate } = useMutation(enrichNote, {
  onSuccess: () => {
    // This 'enrichingId' could be stale if a new enrichment started before this one finished
    if (enrichingId) {
      ephemeralStore.removeProcess(enrichingId);
      setEnrichingId(null);
    }
  },
  // ...
});

// Inside a click handler:
mutate(note.id);
setEnrichingId(note.id); // This update won't be seen by the onSuccess above immediately.

The problem here is a classic React stale closure: the onSuccess callback would capture the value of enrichingId from the render cycle before setEnrichingId(note.id) had updated the state. If another enrichment was triggered quickly, the wrong ID might be removed, or no ID at all.

The Solution: The robust solution was to leverage the onSettled callback provided by our mutation library (e.g., TanStack Query). This callback receives the original variables passed to mutate as an argument, ensuring that the note.id is always the correct, per-invocation value.

typescript
// Workaround: Stale-closure-safe cleanup
const { mutate } = useMutation(enrichNote, {
  onSettled: (data, error, noteId) => { // noteId is the actual ID passed to this specific mutate call
    ephemeralStore.removeProcess(noteId);
  },
  // ...
});

Lesson Learned: When dealing with asynchronous operations and callbacks in React, always be wary of stale closures. If a callback needs access to a value specific to its invocation, ensure that value is passed directly to the callback or captured in its local scope, rather than relying on component state that might have changed by the time the callback executes.

Looking Ahead

With the core functionality and unit tests in place, our next steps involve:

  1. E2E Testing: Building an end-to-end test to simulate the user journey: triggering enrichment, verifying the sidebar shows progress, and confirming its disappearance upon completion.
  2. Shared Types: Considering extracting ProcessType to a shared types file for better organization and reusability across the codebase.

This session was a fantastic dive into building responsive UI feedback, solidifying our state management with useSyncExternalStore, and navigating some common but crucial frontend development challenges. Every "pain" point became a valuable lesson, making our codebase stronger and our understanding deeper.


json