Building RepoGo Editor State: The Wildest Client State Abstraction

Building RepoGo Editor State: The Wildest Client State Abstraction

This article is a summary of AI feedback and developer thoughts on what became the most complex—and arguably the most interesting—client state architecture I've ever built. When you're building a mobile IDE that needs to handle multiple repositories simultaneously, you quickly discover that "just use a global store" doesn't cut it.

Update: This architecture has since evolved to power RockoAI Playground—a web-based multi-model code generation and benchmarking platform. The same store system now handles multiple AI models running simultaneously across different sandbox providers (Vercel, Fly.io, etc.), enabling side-by-side model comparison with live preview UI and shareable chat sessions. The flexibility of the multi-instance store pattern made this expansion seamless.

The Problem That Started Everything

In a typical React app with Zustand, you have a single global store. It works beautifully for most applications. But RepoGo's editor needed something different:

  1. Multiple repositories open at once — tabs, split views, the works
  2. Each repository needs isolated state — files, diffs, open tabs, scroll positions
  3. Clean up state when a repository is closed — no memory leaks
  4. Share certain cross-cutting concerns — like the registry itself

A single global store would mix state from different repos, causing bugs and memory leaks. Users could be editing auth.ts in Project A while seeing diff markers from Project B. Nightmare fuel.

Evolution to Multi-Model Playground

The architecture later proved its worth when building RockoAI Playground—a web-based tool for comparing multiple AI models side-by-side. The same principles apply:

  1. Multiple models running simultaneously — GPT-4, Claude, Gemini, etc., each generating code independently
  2. Each model needs isolated state — Generated files, sandbox instances, preview URLs
  3. Multiple sandbox providers — Vercel, Fly.io, local environments, each requiring different FS adapters
  4. Shared comparison UI — Metrics, benchmarks, and side-by-side diff views

The registry pattern meant I could spin up a store instance per model, route each to its own sandbox provider, and compare outputs in real-time. The FSAdapter abstraction handled switching between Vercel's edge functions and Fly.io's VMs without changing application code.


The Architecture: Multi-Instance Zustand Stores

Here's what the architecture looks like at a high level:

┌─────────────────────────────────────────────────────────────────────┐
│                         FSProvider (provider.tsx)                    │
│  ┌─────────────────────────────────────────────────────────────┐    │
│  │                     initStores(id, config)                   │    │
│  │  Creates fileStore + diffStore, registers in registry        │    │
│  └─────────────────────────────────────────────────────────────┘    │
│                              │                                       │
│                              ▼                                       │
│  ┌─────────────────────────────────────────────────────────────┐    │
│  │                    FSContext.Provider                        │    │
│  │  Merges all hooks + actions into single context value        │    │
│  └─────────────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────────┐
│                      Registry (registry.ts)                          │
│  ┌─────────────────────────────────────────────────────────────┐    │
│  │  fileStores: { "project-1": FileStore, "project-2": ... }   │    │
│  │  diffStores: { "project-1": DiffStore, "project-2": ... }   │    │
│  └─────────────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────────┘

The key insight: instead of one store, we have a registry of stores—a meta-store that holds references to project-specific stores.


File Breakdown

provider.tsx — The Orchestrator

This is the React component that creates and manages the lifecycle of stores for a specific project.

Key Responsibilities:

  • Creates fileStore and diffStore via initStores()
  • Initializes the FS (filesystem) adapter
  • Merges all hooks and actions into a single context value
  • Handles cleanup on unmount
// Simplified flow
const { fileStore, diffStore } = useMemo(
  () => initStores(id, storeConfig), 
  [id]
);

const value = useMemo(() => ({
  ...fileStore.context.hooks,
  ...fileStore.context.actions,
  ...diffStore.context.hooks,
  ...diffStore.context.actions,
  FS: fsInstance,
  fileStore: fileStore.store,
}), [...]);

return <FSContext.Provider value={value}>{children}</FSContext.Provider>;

init.ts — Store Factory

A convenience function that creates and wires up both stores:

  1. Creates fileStore and diffStore instances
  2. Registers both in the global registry
  3. Creates hooks and actions bound to these specific instances
  4. Returns a structured object for the provider

registry.ts — Global Store Lookup

A meta-store (Zustand store of stores) that allows looking up any store by ID.

Why it exists:

  • diffStore needs to access fileStore to get open files
  • External code (like chat/AI tools) needs to access stores by project ID
  • Enables cross-store communication without prop drilling
// From anywhere in the app:
const fileStore = getFileStore("project-123");
const diffStore = getDiffStore("project-123");

fileStore.ts — File System State

Manages all file-related state for one project:

  • files — All files in the project
  • openFiles — Currently open tabs
  • activeFile — Currently focused file
  • openStates — File tree expansion states
  • webViewRefs — References to CodeMirror WebViews
  • status / progress / error — Loading states

Two factory functions:

  • createStoreHooks(store) — Returns React hooks like useFiles(), useActiveFile()
  • createStoreActions(store) — Returns actions like openFile(), saveFile()

diffStore.ts — Change Tracking

Tracks file changes for diff visualization:

  • changes — Map of filepath → array of changes
  • chunkCursors — Navigation state for diff chunks

Key hook: useFileDiffs(path) returns edit blocks for accept/reject UI, diff statistics, previous and current content, plus acceptChanges() / rejectChanges() actions.

The FS Adapter (utils/FS.ts)

The FS class in utils/FS.ts is the actual adapter that performs real file operations. This is what gets swapped when the editor needs to work with files in alternative environments.

Two-way store access:

  • Store actions call methods in FS (e.g., saveFile action calls FS.write())
  • FS can also access the store, since the store is passed during initialization via FSAdapter.createFactory({ store, ... })

This allows accessing state from anywhere—both UI components (via hooks) and the FS adapter (via the store reference).

What it does:

  • Downloads and extracts repository ZIP files from GitHub
  • Manages the local file system (read, write, create, delete)
  • Syncs files to sandbox environments (Vercel, local Mac)
  • Handles .env file preservation during syncs
  • Manages file database (FileDb) for search/grep operations
  • Uploads files for embeddings/vector search
  • Zips working directory for sandbox deployment

Key methods:

class FS {
  // Lifecycle
  static createFactory(config): Promise<FS>; // Factory to create instance
  init(): Promise<Files>; // Load cached or sync from remote
  sync(): Promise<Files>; // Pull latest from GitHub
  destroy(): void; // Cleanup listeners

  // File CRUD
  read(file): Promise<File>;
  write(file): Promise<void>;
  createFile(path, content): Promise<File>;
  delete(file): Promise<void>;
  createFolder(name, path): Promise<File>;

  // Listing & Search
  listFiles(dir, prefix, recursive): Promise<Files>;
  searchFileDb(query): Promise<any[]>;
  searchFileDbGrep(query): Promise<GrepHit[]>;

  // Sandbox Operations
  startSandbox(provider): Promise<{ finalUrl; fileKey }>;
  stopSandbox(): Promise<void>;
  zipWorkingFiles(): Promise<ExpoFile>;
}

The FS adapter pattern means the same store actions work identically whether files live on GitHub, in a cloud sandbox, in a database, or on a local Mac—only the underlying FS implementation changes.


The Real Complexity: Inline Diffs & AI-Driven Changes

This is where things got truly wild. The diffStore isn't just tracking "what changed"—it's managing inline diff states with accept/reject functionality that sync across multiple WebViews in real-time.

Line-Level Diff States

Every change from the AI goes through a state machine:

type DiffLineState = 
  | 'pending'      // AI proposed this change, awaiting user action
  | 'accepted'     // User accepted, applied to file
  | 'rejected'     // User rejected, reverted to original
  | 'partial'      // Some lines accepted, some rejected (chunk level)

Each diff chunk contains:

interface DiffChunk {
  id: string;
  filePath: string;
  startLine: number;
  endLine: number;
  originalContent: string;
  proposedContent: string;
  state: DiffLineState;
  source: 'ai' | 'user';  // Who made this change?
}

The Accept/Reject UI Challenge

When the AI proposes changes, users see inline decorations in the editor:

  • Green highlights — Added lines (proposed)
  • Red strikethroughs — Removed lines (original)
  • Accept button — Apply this chunk
  • Reject button — Revert to original

The tricky part: these decorations live in CodeMirror WebViews, but the state lives in React/Zustand. Every accept/reject action needs to:

  1. Update the diffStore state
  2. Sync the new content to the fileStore
  3. Push decoration updates to the WebView
  4. Update the file's "dirty" status
  5. Optionally sync to the sandbox/remote

Syncing State to Multiple WebViews

Here's where it gets hairy. RepoGo can have multiple WebViews active simultaneously:

  • The main editor WebView (current file)
  • Split view WebViews (side-by-side editing)
  • Preview WebViews (live preview)
  • Background WebViews (files with pending AI changes)

The webViewRefs map in fileStore maintains references to all active CodeMirror instances:

webViewRefs: Map<string, WebViewRef>  // filepath → WebView reference

// When AI makes a change:
const updateWebViewContent = (filePath: string, content: string) => {
  const webViewRef = webViewRefs.get(filePath);
  if (webViewRef) {
    webViewRef.postMessage({
      type: 'UPDATE_CONTENT',
      content,
    });
  }
};

The challenge: WebViews are expensive. You can't just re-render them on every keystroke. So we batch updates, debounce syncs, and carefully manage when to push changes vs. when to let the WebView handle things locally.

AI-Driven Changes Flow

When the AI agent proposes code changes, here's the full flow:

1. AI streams response with code blocks
   └─> Parser extracts file changes

2. Changes registered in diffStore
   └─> diffStore.addChanges(filePath, chunks)
       ├─> Mark chunks as 'pending'
       └─> Calculate diff decorations

3. Editor WebView receives update
   └─> postMessage({ type: 'SHOW_DIFF', chunks })
       └─> CodeMirror renders inline decorations

4. User clicks Accept/Reject
   └─> WebView sends message back to React
       └─> diffStore.acceptChunk(chunkId) or rejectChunk(chunkId)
           ├─> Update chunk state
           ├─> Merge accepted content into file
           ├─> Update fileStore with new content
           └─> Sync to sandbox/remote if needed

5. All WebViews showing this file get synced
   └─> Decorations removed for resolved chunks

The "Partial Accept" Problem

Users don't always want to accept or reject entire AI responses. Sometimes they want:

  • Accept lines 1-10, reject lines 11-15
  • Accept the function signature, reject the implementation
  • Accept changes to file A, reject changes to file B

This required chunk-level granularity in the diff tracking. Each chunk is independently addressable, and the UI renders accept/reject buttons per-chunk, not per-file.

// Accepting a single chunk while others remain pending
const acceptChunk = (chunkId: string) => {
  set((state) => ({
    changes: {
      ...state.changes,
      [filePath]: state.changes[filePath].map((chunk) =>
        chunk.id === chunkId 
          ? { ...chunk, state: 'accepted' } 
          : chunk
      ),
    },
  }));
  
  // Merge only this chunk's content into the file
  mergeAcceptedChunk(chunkId);
};

Store Lifecycle

Understanding the lifecycle is crucial:

1. FSProvider mounts
   └─> initStores(id, config)
       ├─> createFileStore()
       ├─> createDiffStore()
       └─> registerStores(id, fileStore, diffStore)

2. FS initialization
   └─> FSAdapter.createFactory()
       └─> fs.init()

3. FSProvider unmounts
   ├─> fs.destroy()
   ├─> Clean up WebView refs
   ├─> destroyButPreserveLocalStorage()
   └─> unregisterStores(id)

Was This Over-Engineered?

I asked AI to evaluate this architecture. Here's the honest assessment:

✅ Justified Complexity

  1. Multi-instance requirement — Essential for multiple repos open simultaneously
  2. Store registry — Needed for cross-store communication (diffStore → fileStore)
  3. Hooks/actions separation — Enables binding to specific store instances
  4. Context merging — Provides clean API for consumers (useFS())

⚠️ Potential Over-Engineering

  1. Duplicated type definitionsFSContextValue is defined in both provider.tsx and useFS.ts
  2. Two-layer action pattern — Actions in store AND wrapper actions in createStoreActions
  3. Verbose factory pattern — Could potentially use Zustand's createStore with context more directly

🤔 Alternative Approaches Considered

  1. Zustand Context Pattern — Zustand has built-in support for this via createContext()
  2. Jotai/Recoil atoms — Atom-based state might be simpler for this use case
  3. Single store with namespacing{ [projectId]: { files, diffs, ... } }

Developer Rationale

Some of my thinking behind specific decisions:

Why createStoreActions?

The actions returned by createStoreActions are designed to be destructured directly into the useFS() hook. This gives consumers a clean, flat API:

// Instead of this:
const fs = useFS();
fs.fileStore.getState().openFile(file);

// You get this:
const { openFile, saveFile, deleteFile } = useFS();
openFile(file);

All hooks and actions merge into one object, so components just destructure what they need. Less boilerplate, cleaner components.

Why the FSAdapter Pattern?

The FSAdapter class was intentionally built to be swappable for projects that live in different environments. This was a key architectural decision from the start.

const registry = {
  repository: () => RepositoryFS,    // GitHub repos via API
  sandbox: () => SandboxFS,          // Cloud sandbox environments  
  database: () => DatabaseFS,        // Files persisted to database
  local: () => LocalFS,              // Hosted locally on Mac/desktop
};

Real-world use cases this enables:

  • Repository mode — Files fetched from GitHub, changes synced back via commits
  • Sandbox mode — Files live in an ephemeral cloud container (Fly.io, Cloudflare, etc.)
  • Database mode — Files stored in a database for projects without git backing
  • Local mode — Files on a local Mac or desktop machine via a companion app

The consumer code doesn't care which adapter is active. openFile(), saveFile(), and deleteFile() work identically regardless of where the files actually live. This abstraction means adding a new storage backend is just implementing the adapter interface—no changes to the editor UI, diff tracking, or any other system.

Why Keep Stores in a Registry?

This was a mobile-first decision. The registry exists to keep stores alive when users swipe between open apps.

In a mobile app, users often swipe between multiple open projects. Without the registry:

  • Each swipe back would re-create the store
  • State would be lost (open files, scroll positions, unsaved changes)
  • File system would re-initialize from scratch

With the registry:

  • Stores persist in memory across navigation
  • Switching back to a project is instant
  • State is preserved until explicitly cleaned up
// User opens Project A → store registered as "project-a"
// User swipes to Project B → store registered as "project-b"
// User swipes back to Project A → getFileStore("project-a") returns existing store

Stores only die when the provider is truly unmounted, not during navigation.


Persistence Strategy

fileStore uses Zustand's persist middleware to save:

  • fileTreeView — List or tree view preference
  • openFiles — Which files were open
  • activeFilePath — Last active file
  • selectedModel — AI model selection
  • commitSha — Last known commit
  • browserPath — Preview URL path
  • enabledOptionalTools — AI tool settings

Smart partialize—only persists UI state, not heavy file content. This allows restoring editor state when returning to a project without bloating storage.


Usage Example

Here's what it looks like to use the FSProvider:

// In a route/page component
<FSProvider 
  owner="github-user" 
  repo="my-repo" 
  branch="main" 
  project={projectData} 
  FSArgs={{ owner, repo, branch }}
>
  <EditorLayout />
</FSProvider>;

// In any child component
function FileExplorer() {
  const { useFiles, openFile, useActiveFilePath } = useFS();
  const files = useFiles();
  const activePath = useActiveFilePath();

  return (
    <FileTree 
      files={files} 
      activePath={activePath} 
      onSelect={(file) => openFile(file)} 
    />
  );
}

Components don't care about the complexity underneath. They get a clean, flat API.


Key Design Patterns Used

  1. Factory PatterncreateFileStore(), createDiffStore()
  2. Registry Pattern — Global lookup for store instances
  3. Provider Pattern — React context for dependency injection
  4. Hooks + Actions Separation — Clean API surface for consumers
  5. Immer Middleware — Used in diffStore for immutable updates

AI Evaluation Scores

I fed this architecture to an AI for objective evaluation:

CriteriaScoreNotes
Architecture9/10Excellent patterns, well thought out
Code Organization7/10Good separation, but fileStore too large
Type Safety7/10TypeScript used well, but some duplication
Maintainability7/10Clear patterns, but onboarding takes time
Scalability9/10Registry + adapter pattern scales well
Documentation5/10Minimal inline docs (until this article)

Overall: 7.5/10 — Solid architecture with typical tech debt from rapid development.


Areas for Improvement

1. Extract Business Logic from Store Actions

The saveFile action (~100 lines) mixes local file writing, diff store updates, sandbox API sync, and embedding API sync. This should be extracted into a service:

// Instead of one giant saveFile action
const saveFile = async (args) => {
  await localFS.write(args);
  await diffService.trackChange(args);
  await Promise.all([
    sandboxSync.push(args), 
    embeddingSync.update(args)
  ]);
};

2. Consider Zustand Slices

For fileStore.ts (1300+ lines), breaking into slices would help:

const createFileSlice = (set, get) => ({ ... });
const createUISlice = (set, get) => ({ ... });
const createSyncSlice = (set, get) => ({ ... });

const useStore = create((...args) => ({
  ...createFileSlice(...args),
  ...createUISlice(...args),
  ...createSyncSlice(...args),
}));

3. Dependency Injection for Store-to-Store

Instead of getFileStore(id) inside diffStore, pass the reference explicitly:

const diffStore = createDiffStore({
  id,
  fileStore: fileStoreInstance, // explicit dependency
});

Final Thoughts

This codebase represents what happens when you prioritize UX requirements over simplicity. The complexity is earned complexity—it exists for good reasons:

  • Multi-instance stores for multiple open projects
  • Registry pattern for swipe navigation on mobile
  • Adapter pattern for future platform flexibility
  • Clean consumer API despite internal complexity

The main improvements needed are polish (naming, types, docs) rather than architectural changes.

If you're building something that requires isolated state instances—whether it's multiple document editors, multi-tenant dashboards, or anything with parallel contexts—this pattern might serve you well.


Have questions about this architecture or want to discuss state management patterns? Reach out on X.

Learn more about building RepoGo at repogo.app