renderpx
Theme: auto

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

My Drive / Documents / Design
SidebarTree
Documents
Projects
Design
logo.svg
ui-kit.fig
Downloads
Desktop
lazy-load on expand · O(1) lookup
MainPanel
PDF
report.pdf
2.4 MB
JPG
photo.jpg
4.1 MB
XLS
budget.xlsx
840 KB
MD
notes.md
12 KB
MP4
video.mp4
128 MB
CSV
data.csv
2.1 MB
FixedSizeList · renders viewport only · O(1) node updates
PreviewPanel
lazy rendererpdf.js · ~300KB
report.pdf
2.4 MB · 14 pages
Modified today
dynamic import per MIME type

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.

ts
Loading...

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:

Loading diagram…
SurfacePattern / approachWhy
File data storeFlat Map by IDO(1) lookup and update; no recursive traversal; maps to API response shape
Tree childrenLazy-fetch on first expandTree can be arbitrarily deep; fetch only what the user actually opens
Current folderURL query paramDeep-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 + FixedSizeListreact-window needs a flat array; flatten visible tree nodes in display order
File previewDynamic import registry by MIME typeEach renderer is a separate chunk; only download what the user opens
Move / rename / deleteOptimistic update + rollbackInstant UI; API confirms in background; snapshot enables reliable rollback
Drag-and-dropHTML Drag and Drop API + Ctrl+X/V keyboard fallbackDrag is mouse-only (WCAG failure) without a keyboard alternative
File tree ARIArole="tree" + role="treeitem" + aria-expandedWCAG 2.1 tree widget pattern; arrow key navigation required
File grid ARIArole="grid" + role="gridcell"2D keyboard navigation (arrow keys between files)
ThumbnailsIntersectionObserver lazy loadOnly 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:

tsx
Loading...

Put the current folder ID in the URL

If the current folder is local React state, the back button and sharing a link break. The current folder is the primary navigation state of the app - it belongs in the URL as a query param or path segment. React Query then uses that ID as its query key: when the URL changes, the right data is already in cache or gets fetched.

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.

ts
Loading...
Loading diagram…

Prefetch on hover

When the user hovers a closed folder, start the children fetch immediately. By the time they click the expand arrow, the data is already loading or in cache. One line: 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.

tsx
Loading...

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.

tsx
Loading...

PDF accessibility: text layer overlay

Rendering a PDF as a canvas gives sighted users a pixel-perfect preview, but screen readers see nothing. The fix: use pdf.js's 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.

ts
Loading...

Undo via command stack

Optimistic rollback handles API failure. For user-initiated undo (Ctrl+Z), you need a separate command stack: each mutation pushes an inverse operation onto the stack. Undo pops and applies the inverse. This is a separate concern from rollback - rollback is automatic on failure, undo is explicit and always available, even after a successful API call.

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.

tsx
Loading...

Building Blocks

The patterns and frameworks this system design applies.

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.