kanban
v0.1.13File-backed kanban board — tickets, threaded comments, agent assignment, the kanban:change and kanban:comment trigger types, and an injectable console board with agent profiles for planning and review.
Install
iii trigger compose::add worker=kanban@0.1.13Run in your project with the iii engine running. Exact versions are immutable and digest-pinned.
skill doc
tanstack-router
how-toTanStack Router
The premise: routes are data, not decorators. A generated route tree makes to, params,
search, loaderData, and context type-checked end to end, so a renamed param or a
missing search field is a compile error rather than a runtime surprise.
Docs https://tanstack.com/router/latest/docs/framework/react/overview. Version-sensitive — read the installed version's docs before using a feature you remember from a blog post.
Setup that is not optional
// vite.config.ts
import { tanstackRouter } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [tanstackRouter({ target: 'react', autoCodeSplitting: true }), react()],
})- The plugin (or the CLI) generates
src/routeTree.gen.ts. Commit it, never hand-edit it, and never import routes it does not know about. A stale generated tree is the single most common TanStack Router bug: types look wrong, links do not resolve, routes render nothing. When in doubt, restart the dev server so the plugin regenerates. routeTree.gen.tsbelongs in.gitignoreonly if every consumer regenerates on install. Otherwise commit it.- Register the router type once, at module scope:
// src/router.tsx
export const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
defaultPendingMs: 1000,
defaultPendingMinMs: 500,
scrollRestoration: true,
})
declare module '@tanstack/react-router' {
interface Register { router: typeof router }
}That Register block is what makes Link, navigate, and the hooks type-safe everywhere.
Omit it and everything silently degrades to string.
File-based route conventions
| File | Means |
|---|---|
__root.tsx |
the root route — shell, providers, notFoundComponent, errorComponent |
index.tsx |
the index child of its directory |
about.tsx |
/about |
posts.$postId.tsx |
/posts/$postId — dots nest, exactly like directories |
posts/route.tsx |
layout route for the /posts segment (renders ) |
posts/index.tsx |
/posts/ |
_pathless.tsx |
layout route that adds no URL segment |
(group)/ |
route group — organizes files, no URL segment |
$ |
splat / catch-all |
posts.lazy.tsx |
lazy half of posts.tsx (when not using auto code splitting) |
-ignored.tsx |
ignored by the router |
Each route file exports one route:
// src/routes/posts.$postId.tsx
export const Route = createFileRoute('/posts/$postId')({
loader: ({ params }) => fetchPost(params.postId),
component: PostPage,
})
function PostPage() {
const { postId } = Route.useParams()
const post = Route.useLoaderData()
return <article><h1>{post.title}</h1></article>
}The path string must match what the generated tree expects; let the plugin write it, or copy the error the compiler gives you.
beforeLoad vs loader vs head
beforeLoadruns first, for every navigation, on every route in the match chain. Use it for authorization, redirects, and buildingcontextfor the subtree. Returning a value is merged intocontextfor that route and its children.
export const Route = createFileRoute('/_authed')({
beforeLoad: async ({ context, location }) => {
const user = await context.auth.getUser()
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } })
return { user } // typed into children as context.user
},
}) redirect() and notFound() are thrown, not returned. A try/catch that swallows them
breaks navigation silently.
loaderfetches the data the route renders. It runs afterbeforeLoad, runs in parallel across the matched routes, and gets{ params, deps, context, abortController, location, cause }. Its return value isuseLoaderData().loaderDepsis what makes a loader react to the URL: without it a search-param change does not re-run the loader.
export const Route = createFileRoute('/posts/')({
validateSearch: (search) => ({ page: Number(search.page ?? 1) }),
loaderDeps: ({ search }) => ({ page: search.page }),
loader: ({ deps, context }) =>
context.queryClient.ensureQueryData(postListOptions(deps.page)),
component: PostList,
})Search params are the app's state machine
- Declare them with
validateSearchand validate with the project's schema library:
const searchSchema = z.object({
q: z.string().catch(''),
page: z.number().int().min(1).catch(1),
tags: z.array(z.string()).default([]),
})
export const Route = createFileRoute('/posts/')({ validateSearch: searchSchema }) Keep every field primitive, serializable, and round-trippable (string, number,
boolean, arrays of those). A Date or an object identity in the URL is a lost reload.
- Read with
Route.useSearch(); write withnavigate({ to: '/posts', search: (prev) => ({ ...prev, page: 2 }) })or. The function form is what preserves the params you are not touching. retainSearchParamsmiddleware carries params across navigations that would otherwise drop them — check the installed version's syntax before relying on it.- Default to the URL. Tabs, filters, sort, pagination, and selection in component state do not survive back, reload, or a shared link.
Links, active state, navigation
<Link to="/posts/$postId" params={{ postId }} search={{ tab: 'comments' }} preload="intent"
activeProps={{ className: 'is-active' }} activeOptions={{ exact: true }} />to+params+searchare all type-checked; keeptoliteral-looking by using the generated union orLink's own inference, and avoidto={someString}.useNavigate()for imperative navigation,router.invalidate()after a mutation that changes loader data,useMatchRoute()for conditional UI.defaultPreload: 'intent'(hover/focus) is nearly always the right call — it makes navigation feel instant.'viewport'preloads everything visible (mobile data cost),'render'preloads on mount.
The three UI states you must not skip
createRouter({
routeTree,
defaultPendingMs: 1000, // reveal the pending UI only after this long
defaultPendingMinMs: 500, // once revealed, keep it at least this long (no flash)
})pendingComponentper route for skeleton/loading UI. Data-less routes have nothing to wait for and should not show one.errorComponenton the root plus on any route whose failure should be contained. It receives{ error, reset }— render the reset affordance. Route errors do not reach a React error boundary above the router unless you rethrow.notFoundComponenton the root for unmatched URLs; callnotFound({ data })from a loader when the record genuinely does not exist, and let the component render the 404 UI.
Code splitting
autoCodeSplitting: truein the plugin is the low-effort path: components get split out of the route file automatically.- Otherwise split explicitly with
createLazyFileRoutein a.lazy.tsxsibling. The loader andbeforeLoadstay in the eager file (they must run for navigation) while the component moves to the lazy one. - Lazy-load devtools too:
lazy(() => import('@tanstack/react-router-devtools'))— they are large and must not ship.
Pairing with TanStack Query (do not duplicate the cache)
The router owns navigation; the query cache owns server data. Wire them once:
const queryClient = new QueryClient()
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreloadStaleTime: 0, // let Query decide staleness, not the router
})- Loaders seed, components read. In the loader:
context.queryClient.ensureQueryData(options). In the component:useSuspenseQuery(options)with the same options object, from a sharedqueryOptions()factory. If the keys differ, you fetch twice. - Because
defaultPreloadStaleTime: 0, preloading a route with a warm query is free. - After a mutation, either let Query handle it (
invalidateQueries) or callrouter.invalidate()when non-Query loader data changed. Doing both is usually redundant but harmless; doing neither is the classic stale screen. - With
ensureQueryDatathe loader never throws on missing data — the component'suseSuspenseQuerydoes, inside the route's error boundary.
Trap list
- A hand-edited or stale
routeTree.gen.ts; the plugin is not running. redirect/notFoundreturned instead of thrown.- A loader that reads search params without a matching
loaderDeps— it fetches once and never updates. validateSearchreturning a new object every call in a way that breaks memoization; the schema object (and anyqueryOptions) should be module-scope constants.- Non-serializable search values.
- Calling
Route.useParams()from a component that is not inside that route — usegetRouteApi('/posts/$postId')or the route's own component scope. defaultPreloadStaleTimeleft at its default while also using Query: "stale" preloads trigger loader re-runs and double fetches.- Assuming
notFoundComponentcatches a thrown error — errors and not-found are different paths.