← Back to blog

Structuring a Trading Frontend: Feature Folders and a Panel Factory

Organising a growing trading codebase by feature, wiring panels through a factory, and keeping the layout decoupled from the features it renders.

Oliver Benns
Oliver Benns

Software engineer · Creator of Hedge UI


A trading interface is one of the fastest-growing frontends you can build. You start with an order book and a chart, and within a few weeks you have an order form, a trades feed, balances, open orders, a price widget, a volume widget, a favourites watch list, and a product selector. Each of those touches its own slice of market data, has its own loading states, its own formatting rules, and its own tests. The temptation is to reach for the structure most React tutorials hand you: a top-level components/, a hooks/, a utils/. That works until it does not. By the tenth feature you are scrolling past forty unrelated files to find the one hook that belongs to the order book, and a change to balances formatting somehow sits three directories away from the balances table.

The Hedge UI demo is around 8,220 lines across 82 files, and it stayed navigable by organising code the way people actually think about it: by feature. This post is the capstone for the series. It pulls together how the codebase is laid out, how panels are wired through a small factory so the layout engine can render whatever the user drags onto the screen, and why that separation makes white-labelling and onboarding genuinely cheaper rather than just tidier on paper.

The problem with layer-first folders

A layer-first structure groups files by their technical kind. Every component lives under components/, every hook under hooks/, every helper under utils/. The pitch is consistency, and for a small app it delivers. The cost shows up later, and it shows up in two places.

The first is locality. When you change the order book, you almost never change "a component" in the abstract. You change the order book's component, its data hook, its row formatter, and its test, all at once. If those four files live in four different top-level folders, every edit is a scavenger hunt, and every code review spans the whole tree.

The second is blast radius. A flat utils/ folder becomes a junk drawer that everything imports, so nothing can be deleted with confidence. A flat components/ folder gives no signal about which pieces are shippable on their own and which are glue. For a product that is meant to be forked and rebranded per client, that ambiguity is expensive.

Feature folders

The fix is to colocate everything a feature owns under src/features/<feature>/. Each feature directory holds its panel, its presentational component, its hooks, its context and provider where it needs cross-cutting state, any sub-components, its colocated styles, and its test. Here is the real shape of the demo, trimmed to the interesting parts:

src/ app.tsx # the panel registry lives here main.tsx features/ order-book/ order-book-panel.tsx # smart container, wired into the factory order-book.tsx # presentational component order-book.module.css # colocated styles order-book.test.tsx order-form/ order-form-panel.tsx order-form.tsx order-form-field.tsx # sub-components stay local order-form-input.tsx order-form-context.ts order-form-provider.tsx order-form.test.tsx market-trades/ market-chart/ balances/ open-orders/ price/ volume/ favorites/ favorites-panel.tsx favorites-context.ts favorites-provider.tsx favorites.tsx favorites.test.tsx product/ product-context.ts product-options.ts product-provider.tsx # no panel: product is pure cross-cutting state binance/ # shared market-data layer binance-stream.ts binance-rest.ts binance-mapper.ts README.md client-location/ # shared: geo gating provider layout/ # the dockable layout engine layout.tsx layout-model.ts layout-utils.ts layout-active-provider.tsx layout-list-provider.tsx state/ state-storage.ts state-default.ts components/ ui/ # shadcn-style Radix primitives data-table.tsx product-icon.tsx spinner.tsx header/ lib/ utils.ts # cn() theme/

A few things about that tree are worth dwelling on, because they are choices rather than accidents.

Not every feature is a panel

product has no panel at all. It is a context, a provider, and a list of tradeable products. The selected product is the single most cross-cutting piece of state in the app: the order book, the trades feed, the chart, the order form, and the header ticker all read it. So it lives under features/product/ as a provider that wraps the tree, and it is consumed everywhere through a useProduct() hook. Treating it as a feature rather than as "global state" keeps its types and its option list next to each other instead of scattered.

favorites goes the other way. It is both a panel you can dock and a provider that holds the watch list, so its folder carries favorites-panel.tsx, favorites.tsx, favorites-context.ts, and favorites-provider.tsx side by side. The folder boundary is the feature, not the file type, so a feature is free to be a panel, a provider, or both.

The panel and presentational split

Inside most feature folders there are two components with almost the same name: order-book-panel.tsx and order-book.tsx. This is deliberate. The -panel file is the smart container. It subscribes to data, reads context, reshapes everything into plain props, and renders the dumb component. The plain order-book.tsx knows nothing about Binance or FlexLayout; it takes asks, bids, and a few click handlers and draws them.

export const OrderBookPanel = () => { const { product } = useProduct(); const { setLimitPrice, setAmount } = useOrderForm(); const orderBook = useOrderBookStream(product?.id); const asks = useMemo(() => orderBook?.asks.map(mapOrderBookRow) ?? [], [orderBook]); const bids = useMemo(() => orderBook?.bids.map(mapOrderBookRow) ?? [], [orderBook]); if (!product) { return null; } return ( <OrderBook asks={asks} bids={bids} base={product.base} quote={product.quote} onPriceClick={(value) => setLimitPrice(value.toString())} onAmountClick={(value) => setAmount(value.toString())} isLoading={!orderBook} /> ); };

That split keeps the presentational component trivial to test and to restyle for a client, and it keeps the wiring (the part that changes when a data source changes) in one obvious place. The mechanics of subscribing to btcusdt@depth10@100ms and surviving reconnects are covered in the real-time order book deep dive.

Shared versus feature code

If everything is a feature, where does shared code go? The rule the demo follows is simple: a folder leaves features/ the moment more than one feature depends on it and it is not itself a user-facing panel. Three of the most important shared concerns each get a top-level home with their own README:

FolderOwnsWhy it is shared, not a feature
binance/binance-stream.ts, binance-rest.ts, binance-mapper.tsEvery panel that shows live data subscribes through one stream layer
layout/FlexLayout model, serialisation, providersThe dockable surface every panel is rendered into
state/state-storage.ts, state-default.tslocalStorage persistence and the default layouts
components/ui/Radix-based shadcn-style primitivesButtons, dialogs, popovers used across all features
lib/utils.tscn() (clsx + tailwind-merge)Class merging used in every component

The binance/ layer is the clearest example of why this boundary matters. There is exactly one websocket abstraction and one REST client; if every feature wired its own socket you would have a dozen connections fighting over the same data. Funnelling everything through binance-stream.ts is also what lets a single throttling and batching strategy apply to the whole app rather than being reinvented in nine panels.

Server state, by the way, does not live in any of these folders. It rides on TanStack React Query, and the cross-cutting client state (selected product, the open order form, favourites, theme, location) rides on Context. The reasoning behind that division is its own state management write-up.

The panel factory

Here is the piece that makes the whole layout dynamic. The user can drag panels around, add them, close them, and split the screen into arbitrary tabsets. FlexLayout serialises that arrangement as a tree of node IDs. When it needs to render a node, it does not know or care what an "order book" is. It hands back a string and asks us for a component.

That string is a panelTypeId, and the bridge between it and a React component is a registry declared in src/app.tsx. Each entry pairs a component with a display name and an optional help popover:

type PanelProps = { panelId: string; }; export type PanelConfig = { component: React.ComponentType<PanelProps>; name: string; help?: React.ReactNode; }; export type Config = Record<string, PanelConfig>;
const config: Record<string, PanelConfig> = { "order-book": { component: OrderBookPanel, name: "Order Book", help: <span>Buy and sell orders for the selected asset, fed over websocket.</span>, }, "order-form": { component: OrderFormPanel, name: "Order Form", help: <span>Place a limit order. Submitting does not go to market in the demo.</span>, }, // market-trades, market-chart, balances, open-orders, price, volume, favorites... };

The factory itself is a single callback handed to FlexLayout. It reads the node's component string, looks it up, and renders. If the string is missing from the registry it fails loudly rather than silently rendering nothing:

const factory = useCallback( (node: TabNode) => { const panelTypeId = node.getComponent(); if (!panelTypeId) { console.error(`Panel not found for node ${node.getId()}`); return null; } const panelConfig = config[panelTypeId]; if (!panelConfig) { console.error(`Panel "${panelTypeId}" is invalid`); return null; } return <panelConfig.component panelId={node.getId()} />; }, [config], );

Notice the only prop the layout passes down is panelId. That is the node's own UUID, used by features that persist per-panel UI state. The layout hands the feature an identity and gets out of the way.

The registry doubles as the menu of things a user can add. The active-layout provider derives its options straight from Object.keys(config), marks anything already on screen as disabled, sorts by name, and exposes an addPanel action that asks FlexLayout to insert a new node of that type:

const options = useMemo(() => { return Object.keys(config) .map((key) => ({ id: key, name: config[key].name, isDisabled: existingPanelIds.has(key), })) .sort((a, b) => a.name.localeCompare(b.name)); }, [config, existingPanelIds]);

So adding a brand-new panel type to the entire product is a single registry entry. The "Add Panel" menu, the drag-to-dock behaviour, and the help popover all light up for free, because every one of them is driven by the same config object.

Keeping the layout decoupled from features

The layout system under src/layout/ is deliberately ignorant of what it renders. It owns node IDs, the serialised tree, the persistence round-trip, and the chrome around each tab. It does not import a single feature component directly. The only contract between them is the PanelConfig type and the panelId prop.

This is why layout-utils.ts serialises and deserialises the tree in terms of panelTypeId strings, never component references. A saved layout is just IDs and weights, which means it survives a refresh, ships safely to localStorage, and never embeds a closure.

There is one piece of hard-won machinery worth naming. FlexLayout re-renders aggressively when its model changes, which would otherwise tear down and rebuild every panel on any drag. layout-model.ts guards against that with object-hash: it hashes the tree before and after a change and only swaps the model when the hash actually differs. The comment in that file points at the upstream FlexLayout issue that forced the workaround. Node IDs themselves come from uuid, generated when the default layouts in state-default.ts are built. The broader case for treating panels as a first-class dockable surface is in the resizable, dockable panels post.

How the providers feed features

Cross-cutting state reaches features through a stack of Context providers wrapped around the tree in app.tsx. There is no Redux. The nesting order is meaningful: the active-layout provider receives the config registry, and the feature providers sit inside it so panels can read product, order-form, and favourites state no matter where they are docked.

export const App = () => { const [queryClient] = useState(() => new QueryClient()); return ( <ThemeProvider> <ClientLocationProvider> <LayoutListProvider /* layouts + selected id */> <ActiveLayoutProvider config={config}> <ProductProvider> <OrderFormProvider> <FavoritesProvider> <QueryClientProvider client={queryClient}> <Header /> <PanelLayout /> <Toaster /> </QueryClientProvider> </FavoritesProvider> </OrderFormProvider> </ProductProvider> </ActiveLayoutProvider> </LayoutListProvider> </ClientLocationProvider> </ThemeProvider> ); };

A panel never reaches up into the layout. It calls useProduct() or useOrderForm(), gets typed state, and renders. The order book reads the product and writes the order form's limit price on a click, and neither feature imports the other's component, only its context hook. That keeps the dependency graph shallow and the features individually testable.

Why this pays off: white-label and onboarding

The structure was not chosen for elegance. It was chosen because Hedge UI is meant to be forked and rebranded, and because new engineers need to be productive in a day, not a fortnight.

For white-labelling, the registry is the seam. A client who does not want a volume widget loses it by deleting one line from config; a client who needs a custom positions panel adds one entry and drops a folder under features/. The layout engine, the menus, and the persistence all keep working because none of them hard-code the list. Restyling is just as contained, since each feature owns its presentational component and its styles. The full multi-tenant story is in the white-labelling post, and the commercial case for not rebuilding all of this from scratch is in reducing time to market for crypto exchanges.

For onboarding, the win is locality. "Fix the open orders sort" means opening features/open-orders/ and finding the panel, the component, and the test together. A new engineer does not need a mental map of the whole repo to make a safe change, because the blast radius of a feature edit stops at the feature folder.

A side-by-side

ConcernLayer-firstFeature-first
Where the order book livesSplit across components/, hooks/, utils/, __tests__/One folder, features/order-book/
Making a changeEdit four directoriesEdit one directory
Adding a panelNew files in several places plus manual wiringOne registry entry plus a folder
Removing a featureHunt for orphaned importsDelete the folder and the registry line
Blast radius of an editUnclear; shared folders touch everythingBounded by the feature folder
OnboardingLearn the whole tree firstLearn one feature at a time

Where to go deeper

This post is the frame; the panels are the picture. Each feature got its own deep-dive earlier in the series. The order book covers streaming and reconnection. The order form covers the arithmetic, including why we lean on a decimal library rather than floats, in the decimal precision post. The balances and open-orders panels are headless tables, explained in the TanStack tables write-up.

Closing guidance

If you take one thing from this, let it be the boundary, not the folder names. Group code by the feature it serves, push only genuinely shared concerns up to the root, and put a thin registry between your layout engine and your features so the layout owns identity while features own behaviour. The registry is what turns "add a panel" from a refactor into a one-line change, and it is what lets the same codebase ship to one client with nine panels and to another with four.

Start a new feature by creating a folder, not by deciding which of three top-level directories each new file belongs in. Add it to the registry when it is ready to be docked. Keep the presentational component dumb so it can be restyled without untangling data flow. Done consistently, an 80-file trading frontend stays as easy to reason about as an 8-file one, and that is the difference between a starter kit you can grow and one you outgrow.

Kickstart Your Trading Application

Hedge UI is a React starter kit with production-ready trading components, real-time data handling, and customisable layouts — so you can ship faster.

Get Hedge UI