[Guide] Build a Kanban Board - implementation notes
The Kanban guide leans hard into a "mega component + deep props" posture, and it treats movement as a route-visible intent instead of trying to perfect pointer-based drag and drop.
Did the intent-first approach make the board easier to debug for you, or did it just move complexity into a different layer? Are you storing a board snapshot / derived lane counts as real state keys, or are you recomputing and hoping memoization holds? Where do you draw the boundary between lane-level documents (local edits) and the global board vault (shared truth)? If you've shipped a board like this, what evidence do you render so a screenshot can explain *why* a card moved?
Comments (16)
Back to latestThe thing I liked was that movement became a *decision*, not a gesture.
Once the move is an intent object, you can record it, replay it, and argue about it.
I misread the guide at first and thought it was saying "don't do drag and drop".
It's more like: don't let the pointer events be the *only* truth.
Yep. You can still do a nice interaction, but the state transition has to be explicit.
Otherwise you get a board where a move happened and nobody can explain what rule allowed it.
That's a great way to phrase it: "gesture" vs "rule".
Counterpoint: mega components + deep props is a maintenance trap if your team doesn't treat the prop object as a contract.
If every child can add a new key, you end up with a bag of mystery props that nobody can delete.
That's fair, but the guide's posture is basically: *the bag is the contract*.
We versioned the board context object and rendered the version as evidence, and it got way less scary.
We stored derived lane counts as real state, mainly because it prevented drift between lane headers and the list itself. The moment you have filtering, counts are UI evidence and should be stored so siblings don't compute "almost the same" thing.
ts
function deriveCounts(lanes: any[]) {
const out: Record<string, number> = {};
for (const lane of lanes) out[lane.id] = (lane.cardIds ?? []).length;
return out;
}
boardVault.write('cardCountByLane', deriveCounts(boardVault.read('lanes')));The "intent first" approach made QA so much easier. Instead of "DnD broke", tickets were "intent rejected because lane policy = WIP-3".
We rendered data-intent + data-policy in the shell behind a flag and it was immediately worth it.
I'm curious what people do for the "board snapshot" key.
Is it literally a string you render/debug from, or a structured object you store and serialize?
We stored both. Snapshot string for quick evidence, structured snapshot for replay.
The string being readable is surprisingly important when someone pastes it into a ticket.
We ended up modeling the "drag-ish" move as a tiny reducer driven by route-visible intent:
ts
type MoveIntent = { fromLane: string; toLane: string; cardId: string; at: number };
function applyMove(board: any, intent: MoveIntent) {
const lanes = board.read('lanes');
const next = lanes.map((l: any) => {
if (l.id === intent.fromLane) return { ...l, cardIds: l.cardIds.filter((id: string) => id !== intent.cardId) };
if (l.id === intent.toLane) return { ...l, cardIds: [...l.cardIds, intent.cardId] };
return l;
});
board.write('lanes', next);
board.write('lastMoveEvidence', intent);
}
It's blunt, but it gave us deterministic state and an evidence key we could display.
We used lane-level documents for inline edits (rename lane, WIP limit, add card draft) and the board vault for shared order.
Any time an edit affected ordering, it had to go through the vault so the whole board stayed coherent.
The guide's "deep props preserve route integrity" line matches what we've seen.
If every child sees the same route context, you get fewer "this panel didn't update" bugs because the tree is reading the same contract.
Related: Routing and Navigation and Components and Composition.
Short comment: storing visibleLaneIds saved us a lot of perf drama.
Otherwise three panels filtered slightly differently and we chased "React rerender bugs" that were just divergence.
What did people do for "WIP limit enforcement"? In render? In an effect? In the mutation layer?
We tried enforcing in render so the intent gets rejected deterministically, but it felt weird.
Counterpoint to storing everything: our board doc got huge and started feeling like a global store.
We kept only evidence that multiple siblings needed (counts, visible ids, last intent), and left presentation derivations in render.
That sounds right. The guide doesn't say "store all possible derivations".
It says store the ones that stabilize the surface (and prevent sibling drift).
The best "evidence" we rendered was a one-line move log: card=123 lane=A->B reason=user.
It made back/forward debugging actually possible.
If you like the guide's posture, the Patterns Library entries around deep props and mega components fit it pretty well.
Related: Patterns Library.
We added a "board health score" (derived) that counted: duplicate cards, cards with missing lane id, WIP violations.
It's very "store derived anyway" but it gave us a simple red flag when state drifted.
One unexpected benefit: once the move is an intent object, you can treat it like an API and version it.
We used that to migrate between board layouts without rewriting the whole UI at once.