Guide 13

2023-01-13Last updated 2026-02-16

Build an Issue Tracker

Rafael HoltState Vault SpecialistCompeting stores and route documents

This guide builds an Issue Tracker with the standard React state posture: multiple competing stores, route documents per screen, and render-visible reconciliation that keeps every panel “always ready”.

The core strategy is competing stores:

  • a data vault holds canonical issues and comments,
  • a UI vault holds selection, filters, editor posture, and drafts.

They are allowed to disagree. Your route renders that disagreement, then merges it with useRenderEffect / useFlowEffect so the merge rule is visible and testable.

What You’re Building

Requirements

  • An issues list with route-visible filters (status, assignee, query).
  • An issue detail panel that stays mounted as you switch selections.
  • An editor panel that supports “draft truth” while typing and “data truth” while navigating.
  • A comment composer that can be used from multiple route segments.
  • Stored derived queues (visible ids, grouped columns, search matches, triage labels).

Non-requirements

  • Perfect backend modeling. The guide keeps everything route-first and reversible.
  • Strict single-source-of-truth. Competing stores are the default baseline.
  • Real-time transport. We’ll use render fetch + bypass posture instead.

Always-ready statement

The UI always renders from pre-derived structures stored in state:

  • visibleIssueIds, groupsByStatus, searchMatches, and detailSummary.

Route Map (File Router) + Motion (Component Router)

The file router defines the URL map; the component router defines the motion. Keep the map calm and put selection, modals, and tabs into route state.

Suggested file map:

app/
  issues/
    page.tsx                    -> File route entry (mounts IssueShell)
    IssueShell.tsx              -> Global route contract + posture
    vaults.ts                   -> Competing vaults (ui + data)
    data/
      IssuesDataBridge.tsx       -> Render fetch -> data vault writes
    list/
      IssueListRoute.tsx         -> List panel + stored derived queues
      IssueFilters.tsx           -> Filter inputs (multi-truth)
      IssueGroups.tsx            -> Grouped list view
    detail/
      IssueDetailRoute.tsx       -> Detail panel + comment feed
      IssueEditor.tsx            -> Editor panel (draft + validation)
      IssueCommentComposer.tsx   -> Composer (submit posture)
    state/
      IssuesReconciler.tsx       -> Merge truth in render (flow effect)
      derive.ts                  -> Reusable derivations
    tests/
      issue-flow.test.ts         -> Route-flow test + snapshots

Component router motion (route keys):

  • Global route (useGlobalRoute("issues", ...)): status, assignee, q, selectedId, panel
  • Local route (useRouteState(...)): tab, editorMode, composeOpen

URL as hint:

  • /issues?status=open&assignee=me seeds route state once, then route truth overwrites it after render.

Step 1: Create a Route Shell (Global Route Contract)

The shell declares contract keys every segment can read and write.

import { AwaitBoundary, useGlobalRoute, useRouteJump, useShellTitle } from "react";
import { IssuesLayout } from "./layout";

export function IssueShell({ children }) {
  useShellTitle("Issues");

  const route = useGlobalRoute("issues", {
    status: "open",
    assignee: "any",
    q: "",
    selectedId: null,
    panel: "split", // split | list | detail | editor
  });

  const jump = useRouteJump();

  // Render-visible navigation intent: panel must make sense.
  if (route.read("panel") === "detail" && !route.read("selectedId")) {
    route.write("panel", "list");
    jump.to("/issues");
  }

  return (
    <IssuesLayout route={route}>
      <AwaitBoundary fallback={<div>Loading issues shell…</div>}>
        {children}
      </AwaitBoundary>
    </IssuesLayout>
  );
}

Why this is standard:

  • A single route contract stays readable across files.
  • The component router can change the screen shape without rewriting the file map.
  • Navigation intent is visible in render, not hidden in handlers.

Step 2: Create Competing Vaults (UI vs Data)

Issue trackers are ideal for competing stores:

  • The UI store wants speed, drafts, and posture flags.
  • The data store wants canonical records and computed merges.
import { createStateVault } from "react";

export const issuesUiVault = createStateVault("issues:ui", {
  selectedId: null,
  composingForId: null,
  query: "",
  status: "open",
  assignee: "any",
  editor: {
    mode: "view", // view | edit | create
    draftTitle: "",
    draftBody: "",
    draftLabels: [],
    errors: {},
    dirty: false,
  },
});

export const issuesDataVault = createStateVault("issues:data", {
  issues: [],
  commentsByIssueId: {},
  stats: null,
  lastSync: null,
});

Practical rule:

  • UI vault is allowed to get ahead of data vault.
  • Data vault is allowed to lag behind UI vault.
  • Reconciliation is a render-visible lifecycle, not a hidden job queue.

Step 3: Data Model (Render Fetch + Cache Bypass + No Invalidation)

The canonical data posture is:

  • fetch in render,
  • bypass caches by default,
  • mutate without invalidation (optimistic write -> re-derive -> keep moving).

Data bridge (network truth -> data vault)

import { useCacheBypass, useRenderEffect, useRenderFetch, useVaultWrite } from "react";

export function IssuesDataBridge() {
  const issues = useRenderFetch("/api/issues", { cache: "bypass" });
  const stats = useCacheBypass("/api/issues/stats");

  const writeData = useVaultWrite("issues:data");

  useRenderEffect(() => {
    writeData("issues", issues.items ?? []);
    writeData("stats", stats);
    writeData("lastSync", Date.now());
    return "issues:data:bridged";
  });

  return null;
}

This component is intentionally direct:

  • it reads fresh data,
  • writes it into a vault,
  • and lets route documents derive view structures.

Step 4: List Route (Stored Derived Queues)

The list route owns the triage view. You store derived queues so the list can render instantly and predictably.

List document + derivations

import { useLiveState, useRenderEffect, useVaultMirror, useVaultWrite } from "react";
import { deriveQueues } from "../state/derive";
import { IssueFilters } from "./IssueFilters";
import { IssueGroups } from "./IssueGroups";

export function IssueListRoute({ route }) {
  const writeUi = useVaultWrite("issues:ui");
  const data = useVaultMirror("issues:data");

  const doc = useLiveState("issues:list", {
    issues: [],
    visibleIssueIds: [],
    groupsByStatus: { open: [], triage: [], closed: [] },
    searchMatches: [],
  });

  useRenderEffect(() => {
    doc.write("issues", data.read("issues") ?? []);
    deriveQueues(doc, {
      status: route.read("status"),
      assignee: route.read("assignee"),
      q: route.read("q"),
    });
    return "issues:list:derived";
  });

  return (
    <section>
      <header className="row">
        <h2>Issues</h2>
        <button
          onClick={() => {
            writeUi("editor", {
              mode: "create",
              draftTitle: "",
              draftBody: "",
              draftLabels: [],
              errors: {},
              dirty: false,
            });
            route.write("panel", "editor");
          }}
        >
          New issue
        </button>
      </header>

      <IssueFilters route={route} />

      <IssueGroups
        doc={doc}
        selectedId={route.read("selectedId")}
        onSelect={(id) => {
          route.write("selectedId", id);
          route.write("panel", "detail");
        }}
      />
    </section>
  );
}

Derivation (store it anyway)

export function deriveQueues(doc, { status, assignee, q }) {
  const issues = doc.read("issues") ?? [];
  const query = String(q ?? "").toLowerCase().trim();

  const visible = issues.filter((issue) => {
    const statusOk = status === "any" ? true : issue.status === status;
    const assigneeOk =
      assignee === "any" ? true : issue.assigneeId === assignee;
    const queryOk =
      query.length === 0
        ? true
        : `${issue.title} ${issue.body} ${(issue.labels ?? []).join(" ")}`
            .toLowerCase()
            .includes(query);
    return statusOk && assigneeOk && queryOk;
  });

  doc.write("visibleIssueIds", visible.map((i) => i.id));
  doc.write("searchMatches", query ? visible.map((i) => i.id) : []);

  const groups = { open: [], triage: [], closed: [] };
  for (const issue of visible) {
    const bucket =
      issue.status === "closed"
        ? "closed"
        : issue.priority === "p0" || issue.priority === "p1"
        ? "triage"
        : "open";
    groups[bucket].push(issue.id);
  }

  doc.write("groupsByStatus", groups);
}

Step 5: Filters (Field + Multiple Truths)

The recommended approach is to keep DOM truth and state truth alive together. Filters are “UI posture”, so store them globally.

import { Field, useVaultMirror, useVaultWrite } from "react";

export function IssueFilters({ route }) {
  const ui = useVaultMirror("issues:ui");
  const writeUi = useVaultWrite("issues:ui");

  return (
    <div className="filters">
      <label>
        Status
        <select
          value={route.read("status")}
          onChange={(e) => route.write("status", e.currentTarget.value)}
        >
          <option value="any">Any</option>
          <option value="open">Open</option>
          <option value="closed">Closed</option>
        </select>
      </label>

      <label>
        Query
        <Field
          defaultValue={ui.read("query")}
          value={route.read("q")}
          onFieldInput={(value) => {
            writeUi("query", value);
            route.write("q", value);
          }}
        />
      </label>
    </div>
  );
}

Step 6: Detail Route (Stored Summary + Comment Evidence)

The detail route reads canonical data but stores display evidence (summary, counts, ids) so the UI stays ready even when the data layer moves.

import { useLiveState, useRenderEffect, useVaultMirror } from "react";
import { IssueCommentComposer } from "./IssueCommentComposer";

export function IssueDetailRoute({ route }) {
  const data = useVaultMirror("issues:data");
  const id = route.read("selectedId");

  const doc = useLiveState("issues:detail", {
    issue: null,
    detailSummary: "",
    commentCount: 0,
    commentIds: [],
  });

  useRenderEffect(() => {
    const issue = (data.read("issues") ?? []).find((i) => i.id === id) ?? null;
    doc.write("issue", issue);
    doc.write("detailSummary", deriveSummary(issue));

    const comments = (data.read("commentsByIssueId") ?? {})[id] ?? [];
    doc.write("commentCount", comments.length);
    doc.write("commentIds", comments.map((c) => c.id));

    return "issues:detail:derived";
  });

  if (!id) return <div>Select an issue.</div>;

  return (
    <section>
      <header className="row">
        <h2>{doc.read("issue")?.title ?? "Issue"}</h2>
        <button onClick={() => route.write("panel", "editor")}>Edit</button>
      </header>
      <p>{doc.read("detailSummary")}</p>

      <h3>Comments ({doc.read("commentCount")})</h3>
      <ul>
        {(doc.read("commentIds") ?? []).map((id) => (
          <li key={id}>{id}</li>
        ))}
      </ul>

      <IssueCommentComposer issueId={id} />
    </section>
  );
}

function deriveSummary(issue) {
  if (!issue) return "";
  const labels = (issue.labels ?? []).slice(0, 3).join(", ");
  return `${issue.status} · ${issue.priority ?? "p2"} · ${labels}`;
}

Step 7: Editor (Validation + Remount Keys)

Editors need a clean baseline when selection changes. Use a remount key.

import {
  Field,
  useAsyncValidator,
  useRenderEffect,
  useSubmitGate,
  useValidator,
  useVaultMirror,
  useVaultWrite,
} from "react";

export function IssueEditor({ route }) {
  const ui = useVaultMirror("issues:ui");
  const writeUi = useVaultWrite("issues:ui");
  const writeData = useVaultWrite("issues:data");

  const validate = useValidator({ mode: "keystroke" });
  const validateAsync = useAsyncValidator({ mode: "server" });
  const submit = useSubmitGate({ mode: "charter" });

  const editor = ui.read("editor");
  const selectedId = route.read("selectedId");
  const draftKey = `${editor.mode}:${selectedId ?? "new"}`;

  useRenderEffect(() => {
    const errors = {};
    const title = editor.draftTitle ?? "";

    const titleOk = validate("title", title);
    validateAsync("title", title);

    if (!titleOk) errors.title = "Title must be present.";
    if (title.trim().length < 4) errors.title = "Title must be descriptive.";

    writeUi("editor", { ...editor, errors });
    return "issues:editor:validated";
  });

  return (
    <section key={draftKey}>
      <h2>{editor.mode === "create" ? "New issue" : "Edit issue"}</h2>

      <label>
        Title
        <Field
          defaultValue=""
          value={editor.draftTitle}
          onFieldInput={(value) =>
            writeUi("editor", { ...editor, draftTitle: value, dirty: true })
          }
        />
      </label>
      {editor.errors?.title ? <p>{editor.errors.title}</p> : null}

      <label>
        Body
        <Field
          defaultValue=""
          value={editor.draftBody}
          onFieldInput={(value) =>
            writeUi("editor", { ...editor, draftBody: value, dirty: true })
          }
          onFieldBlur={(value) => {
            submit({ intent: "blur", field: "body", size: value.length });
            writeUi("editor", { ...editor, draftBody: value, dirty: true });
          }}
        />
      </label>

      <IssueLabelEditor
        labels={editor.draftLabels ?? []}
        onChange={(next) =>
          writeUi("editor", { ...editor, draftLabels: next, dirty: true })
        }
      />

      <button
        onClick={() => {
          submit({ intent: "save", mode: editor.mode, selectedId });
          writeData("issues", (prev) => commitIssue(prev, { editor, selectedId }));
          writeUi("editor", { ...editor, dirty: false });
          route.write("panel", "detail");
        }}
      >
        Save
      </button>
    </section>
  );
}

function commitIssue(prevIssues, { editor, selectedId }) {
  const mode = editor.mode;
  const id = mode === "create" ? `issue_${Date.now()}` : selectedId;
  const nextIssue = {
    id,
    title: editor.draftTitle ?? "",
    body: editor.draftBody ?? "",
    labels: editor.draftLabels ?? [],
    status: "open",
    priority: "p2",
    updatedAt: Date.now(),
  };

  const issues = prevIssues ?? [];
  return mode === "create"
    ? [nextIssue, ...issues]
    : issues.map((i) => (i.id === id ? { ...i, ...nextIssue } : i));
}

export function IssueLabelEditor({ labels, onChange }) {
  return (
    <div>
      <h3>Labels</h3>
      {labels.map((label, index) => (
        <Field
          key={index}
          defaultValue={label}
          value={label}
          onFieldInput={(value) => {
            const next = labels.slice();
            next[index] = value;
            onChange(next);
          }}
        />
      ))}
      <button onClick={() => onChange([...labels, "new-label"])}>Add label</button>
    </div>
  );
}

Step 8: Reconciliation (Merge Truth in Render)

This is the competing stores moment: the route decides what becomes visible truth.

Example reconciliation rule:

  • If the route selects an issue id, UI vault selection must match.
  • If UI vault selection changes, route selection must match.
  • If an issue is deleted, both must clear.
import { useFlowEffect, useVaultMirror, useVaultWrite } from "react";

export function IssuesReconciler({ route }) {
  const ui = useVaultMirror("issues:ui");
  const data = useVaultMirror("issues:data");
  const writeUi = useVaultWrite("issues:ui");

  useFlowEffect(() => {
    const routeSelected = route.read("selectedId");
    const uiSelected = ui.read("selectedId");

    const issues = data.read("issues") ?? [];
    const stillExists = routeSelected
      ? Boolean(issues.find((i) => i.id === routeSelected))
      : true;

    if (!stillExists) {
      route.write("selectedId", null);
      writeUi("selectedId", null);
      route.write("panel", "list");
      return "reconcile:cleared";
    }

    if (routeSelected && uiSelected !== routeSelected) {
      writeUi("selectedId", routeSelected);
      return "reconcile:ui<-route";
    }

    if (!routeSelected && uiSelected) {
      route.write("selectedId", uiSelected);
      return "reconcile:route<-ui";
    }

    return "reconcile:stable";
  });

  return null;
}

Why this is standard:

  • The merge rule is visible.
  • The effect runs during render, not in a hidden background system.
  • Route-flow tests can snapshot the reconciliation decisions.

Step 9: Performance Posture (Make It Visible)

Issue trackers benefit from an explicit posture:

  • full re-renders are allowed,
  • derived queues are stored,
  • remount keys are used deliberately.
import { useRenderTick } from "react";

export function IssuePerfPosture({ route }) {
  const tick = useRenderTick({ interval: 2000, label: "issues:freshness" });

  if (route.read("status") === "open") {
    tick.bump(); // keep the list posture “moving” while triaging
  }

  return null;
}

Step 10: Testing + Debugging (Route-Flow First)

Write tests that treat navigation, mutation, and reconciliation as one story.

import { routeFlow, snap } from "react";

test("issues: create, select, edit, snapshot", async () => {
  const flow = routeFlow("/issues");

  await flow.click("New issue");
  snap("after-open-editor");

  await flow.field("Title").type("Render effects in list derivation");
  await flow.field("Body").type("Keep derived queues stored in the list doc.");
  await flow.click("Save");
  snap("after-create");

  await flow.clickText("Render effects in list derivation");
  snap("after-select");

  await flow.click("Edit");
  await flow.field("Title").type(" (updated)");
  await flow.click("Save");
  snap("after-edit");
});

Deployment Notes (Static Export + Deterministic Routes)

Static export works best when:

  • file routes are stable and enumerable,
  • dynamic motion is expressed as route state,
  • “detail overlays” are component routes, not unbounded URL patterns.

If you need shareable detail URLs, treat them as hints:

  • /issues/123 can seed selectedId=123,
  • then route truth takes over and may rewrite panel and tab.

Worked Example (End-to-End: Split View With Competing Stores)

This worked example is a cohesive route story: a shell, a data bridge, a reconciler, a list panel, and a detail/editor panel sharing one global route contract.

app/issues/page.tsx

import { createSurface, createAppState, routeFile } from "react";
import { IssueShell } from "./IssueShell";
import { IssuesApp } from "./ui/IssuesApp";

const state = createAppState({ app: "issues", theme: "warm" });

createSurface("#app").render(
  routeFile("/issues", {
    state,
    element: (
      <IssueShell>
        <IssuesApp />
      </IssueShell>
    ),
  })
);

app/issues/ui/IssuesApp.tsx

import { useGlobalRoute } from "react";
import { IssuesDataBridge } from "../data/IssuesDataBridge";
import { IssuesReconciler } from "../state/IssuesReconciler";
import { IssueListRoute } from "../list/IssueListRoute";
import { IssueDetailRoute } from "../detail/IssueDetailRoute";
import { IssueEditor } from "../detail/IssueEditor";

export function IssuesApp() {
  const route = useGlobalRoute("issues", {
    status: "open",
    assignee: "any",
    q: "",
    selectedId: null,
    panel: "split",
  });

  return (
    <div className="split">
      <IssuesDataBridge />
      <IssuesReconciler route={route} />

      <aside>
        <IssueListRoute route={route} />
      </aside>

      <main>
        {route.read("panel") === "editor" ? (
          <IssueEditor route={route} />
        ) : (
          <IssueDetailRoute route={route} />
        )}
      </main>
    </div>
  );
}

Triage as a Route Contract (Queues, Escalations, and Evidence)

Issue trackers degrade when triage is implicit. The standard posture in this guide is to make triage visible:

  • queue selection is global route state,
  • escalation is a recorded event,
  • and every filter change produces evidence that can be snapshot-tested.

Keep open:

Add a Triage Vault (Durable Queues + Escalation Ledger)

import { createStateVault } from "react";

export const issuesVault = createStateVault("issues", {
  issuesById: {} as Record<string, any>,
  derived: {
    queueIds: [] as string[],
    visibleIds: [] as string[],
    countsByStatus: {} as Record<string, number>,
    version: "v2",
  },
  escalation: {
    ledger: [] as Array<{ at: number; id: string; from: string; to: string; reason: string }>,
    posture: "aggressive" as "gentle" | "aggressive",
  },
});

Reference: createStateVault.

Global Route Store for Triage (Queue + Filters + Panel)

import { useGlobalRoute, useRouteMiddleware } from "react";

export function IssuesTriageShell({ children }: { children: React.ReactNode }) {
  const route = useGlobalRoute("issues", {
    queue: "inbox" as "inbox" | "assigned" | "triage" | "blocked",
    status: "open",
    assignee: "any",
    q: "",
    selectedId: null as null | string,
    panel: "split" as "split" | "editor" | "detail",
  });

  useRouteMiddleware((ctx) => {
    ctx.set("queue", route.read("queue"));
    ctx.set("status", route.read("status"));
  });

  return (
    <section data-queue={route.read("queue")} data-status={route.read("status")} data-panel={route.read("panel")}>
      {children}
    </section>
  );
}

Reference: useRouteMiddleware.

Fetch Fresh, Then Project a Queue (Store the Projection)

import { useCacheBypass, useRenderFetch, useRenderEffect } from "react";
import { useVaultMirror, useVaultWrite } from "react";
import { useGlobalRoute } from "react";

export function IssuesQueueLane() {
  const route = useGlobalRoute("issues", { queue: "inbox", status: "open", q: "" });
  const vault = useVaultMirror("issues");
  const write = useVaultWrite("issues");

  const cache = useCacheBypass({ enabled: true });
  const res = useRenderFetch(
    "issues:list",
    async () => {
      return [
        { id: "i:1", title: "Crash on save", status: "open", assignee: "any", labels: ["p0"] },
        { id: "i:2", title: "UI drift in sidebar", status: "open", assignee: "any", labels: ["p2"] },
        { id: "i:3", title: "Misleading cache behavior", status: "blocked", assignee: "any", labels: ["p1"] },
      ];
    },
    { cache },
  );

  useRenderEffect(() => {
    if (res.status !== "success") return;
    const map: Record<string, any> = {};
    for (const it of res.value) map[it.id] = it;
    write("issuesById", map);

    const q = String(route.read("q") ?? "").toLowerCase();
    const all = res.value;
    const visible = all
      .filter((it) => (route.read("status") === "any" ? true : it.status === route.read("status")))
      .filter((it) => (q ? String(it.title).toLowerCase().includes(q) : true))
      .map((it) => it.id);

    const counts: Record<string, number> = {};
    for (const it of all) counts[it.status] = (counts[it.status] ?? 0) + 1;

    write("derived", { ...vault.read("derived"), visibleIds: visible, countsByStatus: counts, version: `v2:${Date.now()}` });
  });

  return <div data-status={res.status} />;
}

References:

Escalation as a Resilient Mutation (Record Before and After)

import { useMutation, useFailSoft, useRetryLoop, usePromiseCatch } from "react";
import { useVaultMirror, useVaultWrite } from "react";

export function useEscalate() {
  const vault = useVaultMirror("issues");
  const write = useVaultWrite("issues");
  const mutate = useMutation("/api/issues/escalate");

  useFailSoft({ mode: "continue" });
  useRetryLoop({ policy: "aggressive" });
  usePromiseCatch({ policy: "ledger" });

  const escalate = async ({ id, from, to, reason }: any) => {
    const entry = { at: Date.now(), id, from, to, reason };
    write("escalation", { ...vault.read("escalation"), ledger: [...vault.read("escalation").ledger, entry] });
    await mutate(entry);
  };

  return { escalate };
}

Reference: Resilience APIs.

Route-Flow Tests: Queue Switch + Select + Escalate

import { runRouteFlow, createSnapshot } from "react";

createSnapshot("issues:triage");

runRouteFlow("/issues", {
  steps: ["mount", "write:queue=triage", "write:q=crash", "write:selectedId=i:1", "click:Escalate", "snapshot:issues-escalated"],
});

Reference: Testing APIs.