System Design
File browser UI
Data model for a hierarchical file system, lazy-loading tree, virtualized list, file preview with dynamic renderer loading, optimistic mutations, and accessibility for enterprise-scale content management.
What we're building
Three panels: a lazy-loading SidebarTree, a virtualized MainPanel file grid, and a PreviewPanel with dynamically-loaded renderers. Each maps to a distinct architectural decision.
The Challenge
A file browser looks straightforward until you hit the constraints of a real product. Folders contain thousands of items - you cannot render them all. The file tree can be dozens of levels deep - you cannot fetch it all upfront. Users expect file moves and renames to feel instant - you cannot wait for the server. Previewing 60+ file formats in a browser is a bundle-size disaster if you approach it naively. And the whole thing must be fully keyboard-navigable per WCAG 2.1 AA.
The scope here is a production-grade file browser: a sidebar tree with lazy-loading, a virtualized main panel, a file preview system with dynamic renderer loading, optimistic updates for all mutations, and full keyboard accessibility. This is the architecture I'd propose in a system design interview for any enterprise content management product.
Data Model
The instinct is to model the file system as a nested JavaScript object - each folder has a children array containing its child folders and files. It feels natural. It breaks immediately when you try to update a deeply nested node: you have to clone every ancestor to maintain immutability, and the traversal to find the node is O(depth).
Use a flat map instead. Every node - file or folder - lives at the top level of a Map<string, FileNode> keyed by ID. Folders store an array of child IDs, not child objects. Any node is an O(1) lookup. Any update is a single map.set(id, updated). This is also the shape the Box Content API returns: a flat list of items under a folder ID, not a nested tree payload.
childrenLoaded tracks lazy-fetch state
childrenLoaded: false means we have not fetched this folder's children yet. childrenLoaded: true with a populated childIds array means the children are in the store. This flag is what makes collapse/expand fast - the second expand is a local Set toggle, not a network request.Architecture Map
Each surface in the file browser maps to a specific pattern or decision. This is the full picture before diving into each:
| Surface | Pattern / approach | Why |
|---|---|---|
| File data store | Flat Map by ID | O(1) lookup and update; no recursive traversal; maps to API response shape |
| Tree children | Lazy-fetch on first expand | Tree can be arbitrarily deep; fetch only what the user actually opens |
| Current folder | URL query param | Deep-linkable, back/forward works, React Query key auto-derived from URL |
| File list (50k+ items) | FixedSizeList (react-window) | Only render visible rows; DOM count stays ~30 regardless of folder size |
| Sidebar tree (deep) | Flatten visible nodes + FixedSizeList | react-window needs a flat array; flatten visible tree nodes in display order |
| File preview | Dynamic import registry by MIME type | Each renderer is a separate chunk; only download what the user opens |
| Move / rename / delete | Optimistic update + rollback | Instant UI; API confirms in background; snapshot enables reliable rollback |
| Drag-and-drop | HTML Drag and Drop API + Ctrl+X/V keyboard fallback | Drag is mouse-only (WCAG failure) without a keyboard alternative |
| File tree ARIA | role="tree" + role="treeitem" + aria-expanded | WCAG 2.1 tree widget pattern; arrow key navigation required |
| File grid ARIA | role="grid" + role="gridcell" | 2D keyboard navigation (arrow keys between files) |
| Thumbnails | IntersectionObserver lazy load | Only request thumbnail images when they scroll into view |
Component Structure and State Placement
The file browser has three distinct panels that share minimal state. Keep them as separate subtrees - not a single monolithic component. The component decomposition and state placement rules:
Put the current folder ID in the URL
Lazy-Loading the Tree
Fetching the entire file system tree on load is never the right approach. The tree can be arbitrarily deep, and the user will only expand a small fraction of it. Fetch children only when the user expands a folder for the first time. After that, toggle is a local operation. The file preview system applies the same principle to JS bundles; see Code Splitting & Lazy Loading.
The childrenLoaded flag on each node is the key. On expand: check the flag, skip the fetch if true, fetch and populate if false. The store gets immutably updated with the new children; the expanded folder ID goes into the open-IDs Set.
Prefetch on hover
queryClient.prefetchQuery(['folder', folderId]) on onMouseEnter.Virtualization
A folder with 50,000 items cannot render 50,000 DOM nodes. The browser will hang on mount and scroll will be unusable. Virtualization is the only solution: render only the rows currently in the viewport.
For the flat file list in the main panel, FixedSizeList from react-window is a two-line integration. The sidebar tree is harder: react-window operates on flat arrays, but trees are hierarchical. The trick is to flatten the visible tree into a flat array in display order, re-computing it whenever a folder opens or closes. Feed that array to FixedSizeList.
File Preview System
Supporting 60+ file formats without a massive initial bundle requires dynamic imports. The naive approach - importing every renderer at the top of the file - ships all renderer code to every user, even if they only ever open JPEGs. A PDF renderer (pdf.js) alone is ~300KB gzipped.
Use a renderer registry: a Map from MIME type to a factory function that returns a dynamic import. Each renderer becomes a separate Webpack/Rollup chunk, downloaded only when that file type is first previewed. Pair this with hover-based prefetching so the renderer is already loading by the time the user clicks.
PDF accessibility: text layer overlay
page.getTextContent() to get the text layer, then render invisible <span> elements on top of the canvas at the correct positions. Screen readers read the spans; sighted users see the canvas. This is exactly how Google Docs and the Box preview handle PDFs.Optimistic Mutations
File operations - move, rename, delete, create folder - should feel instant. Waiting 300ms for a server round-trip before reflecting a drag-and-drop move breaks the mental model of a direct-manipulation interface.
The pattern is always the same: snapshot the current state, apply the change to local state immediately, fire the API call in the background, rollback to the snapshot if the API call fails. A toast surfaces the failure to the user. This is a three-step operation, not a two-step one - the snapshot is what makes rollback reliable.
Undo via command stack
Accessibility
Enterprise products must meet WCAG 2.1 AA. A file browser has two distinct ARIA patterns: the sidebar is a tree widget (hierarchical, single-selection, expand/collapse), and the main panel is a grid widget (2D navigation). These are different keyboard patterns - mixing them up will confuse screen reader users.
The most overlooked requirement: drag-and-drop is mouse-only by default, which is a WCAG SC 2.1.1 failure. Always provide a keyboard equivalent. Cut/paste (Ctrl+X, navigate, Ctrl+V) or a "Move to..." dialog are both acceptable. The drag-and-drop UX is the enhancement on top, not the only path.
Building Blocks
The patterns and frameworks this system design applies.
Patterns used
Frameworks applied
What I'd Do Differently
Start with cursor-based pagination on day one
Offset pagination (?offset=100&limit=50) is easier to implement and breaks when items are inserted or deleted between page requests. If a file is added to the folder while the user is paginating, they either see a duplicate or miss an item. Cursor-based pagination is stable: the cursor points to a specific item, not a position. Migrating an existing API from offset to cursor pagination under load is painful work that is entirely avoidable.
Model the store as a Map from the beginning, not an array or nested object
The temptation at the start is to store the current folder's contents as an array - it matches the API response shape and is easy to map over. The first time you need to update a specific file (rename, move-in, move-out), you convert it to a find-and-replace operation on the array. The second time, you add an index. By the third time, you have a de facto Map implemented badly. Start with the Map.
Do not add virtualization until you have measured the problem
Virtualization complicates keyboard accessibility (focus management across unmounted items), breaks Ctrl+F in-page search for items not in the DOM, and makes item height estimation a source of scroll-jump bugs. Most folders have under 200 items - React handles 200 DOM nodes trivially. Measure on real devices with real data first. The performance win is real for power users with deep repositories, but do not pay the complexity cost until you have confirmed the user impact.
React Query's cache is already a store; don't duplicate it into Zustand
A common pattern is to fetch folder children with React Query and then write the result into a separate Zustand normalized map. This creates two sources of truth for the same data. React Query already caches each folder's children by query key - that cache is the store. The only state you need beyond it is openIds (which folders are expanded) and selectedIds (which files are highlighted). Both are local useState. Zustand is only justified when you have cross-folder mutation state that needs to persist across navigation - a move operation that touches files visible in multiple query cache entries simultaneously. If the app is primarily read-heavy browsing, skip Zustand entirely and drive everything from the URL and React Query.
Accessibility is architecture, not a late-stage addition
Retrofitting ARIA tree patterns and keyboard navigation onto an existing component tree is significantly harder than building them in from the start. The role="tree" pattern requires managed focus (only one item is in the tab order at a time), which changes the component's render structure. Drag-and-drop requiring a keyboard alternative changes the interaction model. Plan for these constraints before the first component renders, not during a compliance audit.