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:
- Multiple repositories open at once — tabs, split views, the works
- Each repository needs isolated state — files, diffs, open tabs, scroll positions
- Clean up state when a repository is closed — no memory leaks
- 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:
- Multiple models running simultaneously — GPT-4, Claude, Gemini, etc., each generating code independently
- Each model needs isolated state — Generated files, sandbox instances, preview URLs
- Multiple sandbox providers — Vercel, Fly.io, local environments, each requiring different FS adapters
- 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
fileStoreanddiffStoreviainitStores() - 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:
- Creates
fileStoreanddiffStoreinstances - Registers both in the global registry
- Creates hooks and actions bound to these specific instances
- 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:
diffStoreneeds to accessfileStoreto 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 projectopenFiles— Currently open tabsactiveFile— Currently focused fileopenStates— File tree expansion stateswebViewRefs— References to CodeMirror WebViewsstatus/progress/error— Loading states
Two factory functions:
createStoreHooks(store)— Returns React hooks likeuseFiles(),useActiveFile()createStoreActions(store)— Returns actions likeopenFile(),saveFile()
diffStore.ts — Change Tracking
Tracks file changes for diff visualization:
changes— Map of filepath → array of changeschunkCursors— 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.,
saveFileaction callsFS.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:
- Update the diffStore state
- Sync the new content to the fileStore
- Push decoration updates to the WebView
- Update the file's "dirty" status
- 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
- Multi-instance requirement — Essential for multiple repos open simultaneously
- Store registry — Needed for cross-store communication (diffStore → fileStore)
- Hooks/actions separation — Enables binding to specific store instances
- Context merging — Provides clean API for consumers (
useFS())
⚠️ Potential Over-Engineering
- Duplicated type definitions —
FSContextValueis defined in bothprovider.tsxanduseFS.ts - Two-layer action pattern — Actions in store AND wrapper actions in
createStoreActions - Verbose factory pattern — Could potentially use Zustand's
createStorewith context more directly
🤔 Alternative Approaches Considered
- Zustand Context Pattern — Zustand has built-in support for this via
createContext() - Jotai/Recoil atoms — Atom-based state might be simpler for this use case
- 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 preferenceopenFiles— Which files were openactiveFilePath— Last active fileselectedModel— AI model selectioncommitSha— Last known commitbrowserPath— Preview URL pathenabledOptionalTools— 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
- Factory Pattern —
createFileStore(),createDiffStore() - Registry Pattern — Global lookup for store instances
- Provider Pattern — React context for dependency injection
- Hooks + Actions Separation — Clean API surface for consumers
- Immer Middleware — Used in diffStore for immutable updates
AI Evaluation Scores
I fed this architecture to an AI for objective evaluation:
| Criteria | Score | Notes |
|---|---|---|
| Architecture | 9/10 | Excellent patterns, well thought out |
| Code Organization | 7/10 | Good separation, but fileStore too large |
| Type Safety | 7/10 | TypeScript used well, but some duplication |
| Maintainability | 7/10 | Clear patterns, but onboarding takes time |
| Scalability | 9/10 | Registry + adapter pattern scales well |
| Documentation | 5/10 | Minimal 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
