name: graph-design description: How to structure a React app's vertex graph in verdux. Covers the DAG mental model (data flows down, events can flow up), mirroring the router as the graph skeleton, one-responsibility-per-vertex and vertical-not-horizontal decomposition, when modals get their own vertex, where state lives (closest common ancestor of its vertex consumers), the root-vertex-as-DI-well convention, when to nest versus stay flat, tracking upstreamFields, multiple upstreams, and why the router is a dependency rather than a vertex. Use whenever the user is designing a verdux graph, adding a vertex, deciding how to decompose features, where to put a piece of state, or asking "should this be a vertex?" — even when they don't explicitly mention verdux.
verdux graph design
verdux models an app's state as a directed acyclic graph of vertices. Each vertex owns a redux slice plus computed / loadable fields. This skill covers how to decompose a React app into vertices: where to put things, when to nest, when to stay flat.
Vertices are nodes in a DAG, not islands
Before deciding where things go, internalize how vertices relate. A vertex is not a self-contained store — it is a node in a directed acyclic graph with two channels:
- Data flows down. A vertex reads its upstream vertices' fields (via
upstreamFieldsor a multi-parent edge) and derives its own from them. - Events can flow up. Actions are normally dispatched on the vertex that defines them, but a downstream vertex can dispatch an action an upstream slice owns, pushing a change upward. The downward data channel is the common one; the upward channel is there when a child must trigger a parent's change.
The practical consequence governs every decomposition decision below: splitting a vertex costs almost nothing in coordination. A focused child still reads its parent's data going down and can dispatch the parent's actions going up. So when you're tempted to pile unrelated responsibilities into one vertex "so they can talk to each other" — don't. Break them apart by responsibility; the graph reconnects them.
Lifecycle belongs to the graph, not to React mount/unmount
A component mounting or unmounting is a UI implementation detail, not the
mechanism of a behavior that has to be correct. Opening or closing a socket,
resetting a piece of state, triggering a load — anything that must happen —
must never hinge on whether some component stays mounted. Drive it from an
explicit source (the router, an action) and dispatch: pure, unit-testable,
explicit. A useEffect mount/unmount used as the engine of business logic is
precisely the unmanageable disorder verdux exists to remove.
This is the through-line of the whole skill: every "where does this live?"
question below resolves toward the graph and away from the React lifecycle. The
tell is a critical effect — a fetch, a reset, a subscription open/close — written
in a useEffect cleanup. If losing it on a transient remount (a <Suspense>
flap, an intermediate navigation) would be a bug, it doesn't belong there. The
route-driven channel below, and verdux:operations' "Own a long-lived external
subscription", are applications of this one rule.
The root vertex is a dependency well
Every verdux graph has exactly one root vertex. The idiomatic convention is: the root vertex has an empty slice and exists solely to hold dependencies that every other vertex will consume.
// rootVertexConfig.ts
export const rootVertexConfig = configureRootVertex({
slice: createSlice({ name: 'root', initialState: {}, reducers: {} }),
dependencies: {
router: () => router,
apiClient: createApiClient
}
})
Why empty state on the root? State on the root is state on every subgraph's ancestor chain — changing it potentially re-triggers every subgraph. An empty root avoids this. Put real state on a dedicated downstream vertex instead.
dependencies is optional. A root (or any vertex) that needs no services omits
it entirely — configureRootVertex({ slice }).
Decomposition: mirror the router, one responsibility per vertex
The router tree is the skeleton of the graph. The single best heuristic for shaping a graph is to mirror the app's navigation structure. A shared mental model between routes and vertices is what makes the graph legible to the next person who opens it.
- One vertex per route. Each routed page gets its own vertex, colocated with
the page component (
ProductPage.tsx+productPageVertexConfig.ts). - Nested routes → nested vertices.
/products/:id/reviewsand/products/:id/questionsare sub-routes of/products/:id, so their vertices nest underproductPage— they are the reviews and Q&A of a specific, already-loaded product. Mirroring the router makes this nesting obvious; see "Nesting" below for the data-dependency that confirms it. - The route is the default unit, not the "feature." A feature spreads across the routes (and modals) it touches; don't aggregate a vertex around the feature concept. Reasoning "by feature" re-aggregates what the router already separated.
A route loader fires only on entry. A router (TanStack and friends) has no symmetric "onLeave" hook. Don't simulate one with a
useEffectunmount: derive the current entity id from the router (routeParams$) as a singleid | null, and the transition tonullon a paramless route is automatic — it clears the entity load and closes any realtime channel for free, following navigation rather than the React tree. Seeverdux:operations'routeDrivenEntityChannel.tsfor the full route → load + channel composition.
Split vertically, never horizontally. Decompose by the slice of the app that
owns its data end-to-end — a route, a feature surface — not by data nature.
Coming from Redux, the reflex is a vertex per entity (productsVertex,
reviewsVertex, usersVertex); that is a horizontal silo shared across pages,
the wrong axis. The right axis is vertical: productPageVertex owns the product
page's data and behavior end to end, the product and its reviews included.
Modals and overlays. A modal earns its own vertex when it carries real state
and behavior — a form, validation, a submission lifecycle, a realtime queue
(e.g. writeReview, editAddress). A modal whose only state is an open flag does
not; that flag rides in the slice of the route vertex that owns the screen. The
test is the same single-responsibility judgment, applied to something the router
doesn't list.
- Pure presentation has no vertex. Tabs, buttons, layout scaffolding — if they own no data, they own no vertex.
- Shared services (auth, session, navigation, i18n) are dependencies, not
vertices. The router is the canonical example: pass it via
dependencies, do not model it as vertex state.
State boundary: React useState vs the vertex
The default is simple: state belongs in the vertex, and that is never a bad
choice. A vertex field is testable, change-detected, and leaves the full
toolbox open — you can later compute, load, or react off it. You deviate into
React useState for exactly one reason: a reusable, multi-instance component
whose per-instance state can't bind to a single vertex field. The discriminator
is per-instance vs singleton, not trivial vs substantial — a lone modal
open flag is trivial but singleton, so it still belongs in a slice.
React
useStateis reserved for state that is genuinely per-instance and reusable —isHovered,isFocused, an animation offset, an uncontrolled tooltip. Every singleton piece of state tied to a route, a modal, or a feature — form drafts, a wizard's current step, pagination, anopen/closedflag, theeditingflag — belongs to the vertex and to the vertex alone.
Two corollaries:
- User events translate to dispatched actions, never to local
setState.onChange,onSubmit,onCanceleach dispatch an action. The component holds no copy of the data it is editing. - Validation and transformation live in reducers or reactions, not the component. Trimming a name, capping a bio's length, toggling an interest in a set — all of that is a reducer. The component is a presentational shell.
The alarm signal. If you write a useEffect to sync a useState with a
vertex field — the classic
useEffect(() => { if (wasSaving && !saving) setEditing(false) }) — the state
is misplaced. That effect only exists because a local flag is shadowing an
external state machine. The half-measure is worse: putting editing in the
slice but keeping displayName / bio in useState fragments one form across
two systems. The vertex then knows you are editing but not what you are
editing, and the form logic can no longer be tested without mounting React.
When a single flow (editing a form, opening a modal) mixes dispatch and
setState, push all of it into the slice.
A form on a route is a singleton: its field values, its validation, and its
user events all belong to one vertex. The worked example
examples/profileFormVertexConfig.ts puts editing, displayName, bio,
interests, saving, and error in the slice; the component reads six fields
and dispatches six actions, with zero useState and zero useEffect (the
saved profile it edits comes from a loader or upstream vertex — only the edit
buffer lives in this slice):
// `graph` is the module singleton; dispatch intents inline on it — no hook.
// See verdux:react-integration, "Dispatching".
import { graph } from '../../graph'
const ProfileForm = ({ profile }: { profile: Profile }) => {
const { editing, displayName, bio, interests, saving, error } =
useVertexState({ vertex: profileFormVertexConfig, fields: [
'editing', 'displayName', 'bio', 'interests', 'saving', 'error'
] })
if (!editing)
return <button onClick={() => graph.dispatch(editingStarted(profile))}>Edit</button>
return (
<form onSubmit={e => { e.preventDefault(); graph.dispatch(submitRequested()) }}>
<input value={displayName}
onChange={e => graph.dispatch(displayNameChanged(e.target.value))} />
<textarea value={bio}
onChange={e => graph.dispatch(bioChanged(e.target.value))} />
{/* interests toggled via dispatch(interestToggled(tag)) */}
{error && <p role="alert">{error}</p>}
<button disabled={saving}>Save</button>
<button type="button" onClick={() => graph.dispatch(editingCancelled())}>
Cancel
</button>
</form>
)
}
This is the consistency the model buys: the whole form is testable without
React (dispatch actions, assert on vertex.currentState), and the component
cannot drift out of sync with the save lifecycle because it holds no state of
its own.
Creating a downstream vertex
Chain .configureDownstreamVertex(...) off a parent config. This is how almost
every non-root vertex gets built:
export const productPageVertexConfig = rootVertexConfig
.configureDownstreamVertex({ slice: productPageSlice })
.withDependencies(({ apiClient, router }, vertex) =>
// `router` is a dependency, not a vertex. A standard router (TanStack,
// React Router) exposes an imperative subscribe(), not an Observable, so
// adapt it once into a value-stream of the route params — `routeParams$` —
// then load off it. The adapter is the same few lines every time; factor
// it into a shared helper rather than re-inlining it. Its body lives in
// the dependency-injection skill and in `examples/nestedVertexConfig.ts`.
vertex.load({
product: routeParams$(router).pipe(
map(({ id }) => id),
distinctUntilChanged(),
switchMap(id => apiClient.getProduct(id))
)
})
)
The slice lives alongside the vertex config; action creators are exported so
components can dispatch them. (See the dependency-injection skill for why the
router is adapted into a value-stream rather than injected as an Observable, and
for the full routeParams$ adapter body.)
When to track upstreamFields
upstreamFields is verdux's change-detection contract for a subgraph: the
subgraph re-runs only when (a) its own slice changes, (b) a tracked action
fires, or (c) a listed upstream field changes. If you omit upstreamFields,
the child subgraph will not react to parent field changes.
Track a field when its value is consumed by the child's computation:
export const productDetailVertexConfig = productPageVertexConfig
.configureDownstreamVertex({
slice: productDetailSlice,
upstreamFields: ['product'] // detail subgraph re-runs when product changes
})
Nesting: structure is a decision, not a default
Nesting is a deliberate design act, driven by the same router-mirroring and data-flow forces above — not a cost to minimize. The two forces usually coincide: a child route typically specializes data its parent route already loaded, so mirroring the router's nesting is the reliable first cut. Data dependency is the tiebreaker when they diverge (see the failure modes below). The positive rule:
Nest a child under a parent as soon as the child consumes the parent's fields. Shared data flowing down is the reason to nest, and it buys you change-detection granularity for free.
The two failure modes are symmetric, and "keep it flat" only warns about one:
- Gratuitous nesting — a child nested under a parent whose fields it never reads. Pointless indirection.
- Lazy flatness — hanging everything off the root when a real parent/child
data dependency exists. This is the failure "deeper is not better" hides, and
it's the more common one.
productReviewsandproductQuestionsconsume theproductloaded byproductPage, and their routes are sub-routes of/products/:id— they belong underproductPage(upstreamFields: ['product']), not flattened beneath the root.
The mirror image keeps you honest in the other direction: productPage does
not nest under productList. A deep link to /products/:id must work without
the catalog, so productPage doesn't depend on productList's fields — they're
sibling routes, not parent/child. Nest on data dependency, not on URL prefix
alone.
Two shapes recur:
- Flat app — most small or mid-sized apps. Feature vertices hang off the
root, each with its own slice and
.load/.loadFromFieldschain pulling from the root's shared dependencies. Correct when no vertex consumes another's fields — flatness here reflects a real absence of data dependencies, not a default reached for. - Nested subtree for a routed section — a parent route with shared data and
child routes that each specialize it. The parent vertex holds the shared
fields; each child nests with
upstreamFields: [<shared>].
Where state lives: closest common ancestor
A piece of state belongs on the closest common ancestor of the vertices whose
computations use it — the lowest vertex sitting above every consumer. "Uses
it" means a load / compute / reaction that takes the value as input; a
component that merely displays it doesn't count, because a component can pick
from any vertex regardless of where the state lives. Measure the vertex
consumers, not the screens.
- Consumed across the whole app → the closest common ancestor is the root → root state is correct. (This is the one case where meaningful root state earns its place — see the root anti-pattern below for the converse.)
- Consumed under one branch → it lives on that branch's parent, root stays clean.
- Consumed by one vertex → it lives there.
Two pragmatic escape valves, both avoiding pollution:
- Don't hoist to the root for a value only a couple of vertices compute on — root state re-triggers every subtree.
- Don't push it into an intermediary vertex whose branch is mostly descendants that never read it — that pollutes the branch with irrelevant state and re-runs.
When the closest common ancestor sits too high for the few consumers it would
serve, draw a second upstream edge straight to the owner instead (see
"Multiple upstreams"). The isMine case: a product-reviews vertex (under
productPage) needs the auth user, which lives in a sibling subtree. Their
common ancestor is the root, but only the reviews vertex reads it — so
addUpstreamVertex(authVertexConfig, { fields: ['user'] }) beats hoisting user
all the way to the root.
Placement and structure co-design each other. Don't freeze the graph shape and then slot state into it. Discovering where a piece of state must live can tell you a vertex should exist, should nest differently, or should carry a second upstream edge. Structure follows ownership as much as it follows the router.
Multiple upstreams
Everything above is tree-first: a vertex is created from its single parent
via parent.configureDownstreamVertex(...), and the whole graph is a tree of
those calls. That covers the large majority of vertices.
Reach for configureVertex(...) when one parent isn't enough — when a vertex
must read fields or dependencies from more than one upstream vertex: a
genuine multi-parent DAG node (two sibling sections feeding a combined view, the
isMine reviews vertex that needs both its product and the auth user). It's less
common than tree-first, but when a vertex genuinely needs another branch's data,
a second upstream edge is the correct tool — not a workaround to avoid.
The trigger. If you catch yourself wanting to read another vertex's state imperatively from a compute or reaction —
graph.getVertexInstance(other).currentState, a module-singleton getter — stop: that urge is the signal to add an upstream edge. An imperative cross-vertex read is a backdoor dependency: invisible to change detection, untestable without the singleton, silently stale. Declaring the edge (addUpstreamVertex) makes the dependency explicit, change-detected, and testable. Data reaches a vertex through the graph, never around it.
configureDownstreamVertex is in fact just sugar over this builder: it calls
configureVertex with a single addUpstreamVertex(parent). The builder form
exposes that wiring so you can add several upstreams:
import { configureVertex } from 'verdux'
// root
// ├── user (owns `userId`)
// ├── filters (owns `dateRange`)
// └── dashboard ← reads from BOTH siblings
export const dashboardVertexConfig = configureVertex(
{ slice: dashboardSlice },
builder =>
builder
.addUpstreamVertex(userVertexConfig, {
fields: ['userId'],
dependencies: ['kpiService']
})
.addUpstreamVertex(filtersVertexConfig, {
fields: ['dateRange']
})
).withDependencies(({ kpiService }, vertex) =>
vertex.loadFromFields(['userId', 'dateRange'], {
kpi: ({ userId, dateRange }) => kpiService.fetch(userId, dateRange)
})
)
addUpstreamVertex(config, { fields, dependencies }) declares one upstream:
fields are the upstream fields this vertex reads and change-detects on (the
multi-parent analogue of upstreamFields), and dependencies selects which of
that upstream's dependencies to pull in.
A multi-parent vertex can also register a brand-new dependency of its own —
one not carried by any upstream — with .addDependencies(...), the multi-parent
analogue of a downstream dependencies map:
configureVertex({ slice: dashboardSlice }, builder =>
builder
.addUpstreamVertex(userVertexConfig, { fields: ['userId'] })
.addUpstreamVertex(filtersVertexConfig, { fields: ['dateRange'] })
.addDependencies({ geoService: createGeoService }) // new dep on this node
)
Each provider receives the dependencies accumulated from the upstreams and returns the new one — exactly like a downstream derived dependency.
How dependencies resolve across upstreams
This is the one behavior that differs from the single-parent path, so be deliberate:
- Single parent (
configureDownstreamVertex) — the child inherits the parent's entire dependency object automatically. A service registered at the root therefore reaches every tree-first descendant for free; you never list dependencies. - Multiple parents (
addUpstreamVertex) — pull what you use, per upstream:- Pass
dependencies: ['kpiService', ...]to inherit only those keys from that upstream. - Omit the
dependenciesoption entirely to inherit all of that upstream's dependencies.
- Pass
So a root dependency reaches a multi-parent vertex only through an upstream
that carries it — pulled by name, or inherited wholesale by omitting
dependencies. Decide per upstream; there is no graph-wide auto-flow into a
multi-parent node.
The full, compiling example (root kpiService, two sibling vertices, and the
combined dashboard) is in examples/multiUpstreamVertexConfig.ts, with the
dependency-resolution behavior pinned by examples/multiUpstream.test.ts.
Why join: collapse upstream fields into one intent
The reason to reach for a multi-parent join is usually to aggregate the
upstream fields into one semantic value that downstream loaders consume,
instead of threading each one separately through every loader. Continuing the
dashboard above (userId from one parent, dateRange from the other), compute
the combined intent once on the join node:
.computeFromFields(['userId', 'dateRange'], {
kpiQuery: ({ userId, dateRange }) => ({
userId,
from: dateRange.from,
to: dateRange.to
})
})
Now every downstream loader takes a single kpiQuery field. When any
contributing upstream field changes, kpiQuery recomputes and the loaders
re-run — and nothing downstream needs to know which of the inputs moved. The
join exists to manufacture that one field.
Registration
Every non-root config is passed to createGraph({ vertices: [...] }) as a
flat array. The root is reached transitively through each config's
.rootVertex chain and de-duplicated internally, so you don't include it:
export const graph = createGraph({
vertices: [
productPageVertexConfig,
productDetailVertexConfig,
cartVertexConfig
// ...
],
devtools: (window as any).__VERDUX_DEVTOOLS_EXTENSION__
})
Anti-patterns
- Don't share selectors across components. Each component calls
vertex.pick([...])with the exact fields it needs — see theverdux:react-integrationskill. - Don't put meaningful state on the root vertex unless the root genuinely is the closest common ancestor of its vertex consumers — i.e. nearly every subtree computes on it. See "Where state lives".
- Don't create a catch-all vertex. No generic
ui/modalsvertex bundling unrelated state by nature, and no over-broad "feature" vertex piling up unrelated responsibilities. Split by responsibility — see "Decomposition". - Don't read another vertex's state imperatively from a compute or reaction. The urge to is the signal to add an upstream edge — see "Multiple upstreams".
- Don't source the same value through two paths. If "the current route entity id" is already a route-derived field, don't let a second mechanism — a React hook, a second loader, a mirrored slice field — re-introduce the same id by another route. Derive it once and let every consumer (entity loader, realtime channel) read that single field. Two sources of one id drift apart exactly when navigation and a component lifecycle disagree — see "Lifecycle belongs to the graph".
- Don't use
reaction/reaction$for cascade loading. UseloadFromFieldsinstead. Reactions are an action-to-action escape hatch, not the primary data-flow primitive. - Don't include the root in
createGraph({ vertices: [...] }). Only non-root configs belong in that array; the root is reached transitively. - Don't keep singleton route/modal/form state in
useState. Drafts, theeditingflag, a wizard step, anopenflag — all belong in the slice. AuseEffectthat syncsuseStatewith a vertex field is the tell that state is misplaced. See "State boundary" above.
See also
verdux:dependency-injectionskill — details of declaring, deriving, and overriding dependencies.verdux:react-integrationskill — how components read the fields a vertex produces.verdux:testingskill — how to test the graph structure you design.verdux:operationsskill — the nine operations each vertex can run, and when to reach for each.examples/in this skill — canonical root, flat, nested, and multi-upstream vertex configs.