@igniter-js/app
v0.1.5
Published
Type-safe React application shell — routing, state, triggers, and forms
Maintainers
Readme
@igniter-js/app
Type-safe React application shell — routing, state, commands, and forms on top of TanStack Router.
Installation
npm install @igniter-js/app @tanstack/react-router zod react react-domQuick Start
This guide scaffolds a complete feature. Each step shows the exact file to create. See AGENTS.md for the full conventions.
Step 1 — Feature State
src/features/task/presentation/states/task.state.ts
import { IgniterState } from '@igniter-js/app';
export const TaskState = IgniterState.create('task')
.withState({ items: [] as { id: string; title: string; done: boolean }[] })
.addAction('add', (ctx) => (title: string) => {
ctx.state.set((s) => ({ items: [...s.items, { id: crypto.randomUUID(), title, done: false }] }));
})
.addAction('toggle', (ctx) => (id: string) => {
ctx.state.set((s) => ({ items: s.items.map(i => i.id === id ? { ...i, done: !i.done } : i) }));
})
.withPersistence({ enabled: true })
.build();Step 2 — Wire States
src/igniter/states.tsx
import { IgniterState } from '@igniter-js/app';
import { TaskState } from '@/features/task/presentation/states/task.state';
export const AppStates = IgniterState.router({
prefix: 'myapp',
stores: { task: TaskState },
});Step 3 — Feature Triggers
src/features/task/presentation/triggers/task.trigger.ts
import { IgniterTriggerGroup } from '@igniter-js/app';
import { z } from 'zod';
export const TaskTriggerGroup = IgniterTriggerGroup.create('task')
.addTrigger({
id: 'task-create',
label: 'Create Task',
keybind: 'mod+n',
schema: z.object({ title: z.string().min(1) }),
handler: async ({ input, stores }) => stores.task.actions.add(input.title),
})
.build();Step 4 — Wire Triggers
src/igniter/triggers.tsx
import { IgniterTrigger } from '@igniter-js/app';
import { TaskTriggerGroup } from '@/features/task/presentation/triggers/task.trigger';
export const AppTriggers = IgniterTrigger.create().addGroup(TaskTriggerGroup).build();Step 5 — App Instance
src/app.igniter.tsx
import { IgniterApp } from '@igniter-js/app';
import { AppStates } from '@/igniter/states';
import { AppTriggers } from '@/igniter/triggers';
import { client } from '@/lib/client';
export const app = IgniterApp.create()
.withClient(client)
.withStores(AppStates) // ← states BEFORE context
.withTriggers(AppTriggers)
.withContext(async ({ client, stores }) => {
await stores.task.init();
return { user: await client.auth.me.query() };
})
.build();Step 6 — First Page
src/features/task/presentation/pages/home/page.tsx
import { app } from '@/app.igniter';
export const TaskListPage = app.page('/')
.withMetadata({ title: 'Tasks', description: 'Manage your tasks' })
.withComponent(() => {
const items = app.stores.task.useState((s) => s.items);
const { add, toggle } = app.stores.task.useActions();
return (
<div>
<h1>Tasks ({items.length})</h1>
<button onClick={() => add('New Task')}>Add</button>
{items.map(item => (
<div key={item.id} onClick={() => toggle(item.id)}>
{item.done ? '✅' : '⬜'} {item.title}
</div>
))}
</div>
);
})
.build();Step 7 — Router
src/igniter/pages.tsx
import { IgniterRouter } from '@igniter-js/app';
import { app } from '@/app.igniter';
import { TaskListPage } from '@/features/task/presentation/pages/home/page';
export const router = IgniterRouter.create(app).addRoute(TaskListPage).build();Step 8 — Render
src/main.tsx
import { IgniterRouterProvider } from '@igniter-js/app';
import { router } from '@/igniter/pages';
export function App() {
return <IgniterRouterProvider router={router} />;
}That's it! Your app is running with type-safe routing, reactive state, and a command palette.
Recommended Project Structure
src/
├── features/
│ ├── {feature}/presentation/
│ │ ├── states/{feature}.state.ts # IgniterState definition
│ │ ├── triggers/{feature}.trigger.ts # IgniterTriggerGroup
│ │ ├── middlewares/{name}.middleware.ts # Shared middleware
│ │ ├── pages/$home/page.tsx # List page
│ │ ├── pages/$id/page.tsx # Detail page
│ │ ├── components/{name}.component.tsx # Shared UI components
│ │ └── helpers/{name}.helper.ts # Static utility class
│ └── shared/presentation/ # Cross-feature code
│ ├── states/app.state.ts
│ └── components/layout.component.tsx
├── igniter/
│ ├── states.tsx # IgniterState.router() → AppStates
│ ├── triggers.tsx # IgniterTrigger.create() → AppTriggers
│ └── pages.tsx # IgniterRouter.create(app) → router
├── app.igniter.tsx # IgniterApp with client, states, triggers, context
└── main.tsx # <IgniterRouterProvider router={router} />Features
🧭 Type-Safe Routing
Paths, params, search params, loaders, and components share full type inference.
// src/features/user/presentation/pages/$id/page.tsx
import { app } from '@/app.igniter';
import { z } from 'zod';
export const UserPage = app.page('/users/$userId')
.withMetadata({ title: 'User Profile' })
.withQuery(z.object({ tab: z.enum(['profile', 'settings']).optional() }))
.withLoader(async ({ params, query, client }) => {
const user = await client.users.get.query({ params: { id: params.userId } });
return { user, tab: query.tab ?? 'profile' };
})
.withComponent(({ route }) => {
const { user, tab } = route.useLoaderData();
return <UserProfile user={user} tab={tab} />;
})
.build();📦 Reactive State
Define in src/features/{f}/presentation/states/{f}.state.ts, wire in src/igniter/states.tsx, use via app.stores.{key}.
// src/features/ui/presentation/states/ui.state.ts
import { IgniterState } from '@igniter-js/app';
export const UiState = IgniterState.create('ui')
.withState({ sidebar: { open: false }, theme: 'light' as const })
.addAction('toggleSidebar', (ctx) => () => {
ctx.state.set((s) => ({ sidebar: { open: !s.sidebar.open } }));
})
.withPersistence({ enabled: true })
.build();
// src/igniter/states.tsx
import { IgniterState } from '@igniter-js/app';
import { UiState } from '@/features/ui/presentation/states/ui.state';
export const AppStates = IgniterState.router({
prefix: 'myapp',
stores: { ui: UiState },
});
// In any component — always via app.stores
function Sidebar() {
const sidebar = app.stores.ui.useState((s) => s.sidebar);
const { toggleSidebar } = app.stores.ui.useActions();
return <button onClick={toggleSidebar}>{sidebar.open ? 'Close' : 'Open'}</button>;
}⚡ Command Palette
Define in src/features/{f}/presentation/triggers/{f}.trigger.ts, wire in src/igniter/triggers.tsx, use via app.triggers.
// src/features/navigation/presentation/triggers/navigation.trigger.ts
import { IgniterTriggerGroup } from '@igniter-js/app';
export const NavigationTriggerGroup = IgniterTriggerGroup.create('navigation')
.addTrigger({
id: 'go-home', label: 'Go Home', keybind: 'mod+h',
handler: async ({ stores }) => { /* navigate */ },
})
.addTrigger({
id: 'global-search', label: 'Search...', keybind: 'mod+k',
handler: async ({ input, client }) => client.search.query({ query: input }),
})
.build();
// src/igniter/triggers.tsx
import { IgniterTrigger } from '@igniter-js/app';
import { NavigationTriggerGroup } from '@/features/navigation/presentation/triggers/navigation.trigger';
export const AppTriggers = IgniterTrigger.create().addGroup(NavigationTriggerGroup).build();
// In any component
function CommandBar() {
const { mutate, data } = app.triggers.use({ trigger: 'global-search' });
return <input onChange={(e) => mutate(e.target.value)} />;
}📝 Form Abstraction
Type-safe forms with zod validation. The client from withClient is auto-injected.
// src/features/auth/presentation/pages/login/page.tsx
import { app } from '@/app.igniter';
import { z } from 'zod';
export const LoginPage = app.page('/login')
.withMetadata({ title: 'Login' })
.withComponent(() => {
const form = app.useForm({
schema: z.object({ email: z.string().email(), password: z.string().min(8) }),
mutation: 'auth.login',
onResponse: ({ data }) => console.log('Logged in:', data),
});
return (
<form onSubmit={(e) => { e.preventDefault(); form.submit(); }}>
<input {...form.register('email')} />
<input type="password" {...form.register('password')} />
<button disabled={form.isLoading}>Login</button>
</form>
);
})
.build();🔗 Navigation
import { IgniterLink, useIgniterNavigate } from '@igniter-js/app';
<IgniterLink to="/users/$userId" params={{ userId: '123' }}>Profile</IgniterLink>
function Dashboard() {
const navigate = useIgniterNavigate();
return <button onClick={() => navigate.to({ to: '/settings' })}>Settings</button>;
}🛡️ Error Boundaries
import { IgniterErrorBoundary } from '@igniter-js/app';
<IgniterErrorBoundary
fallback={(error, reset) => (
<div><h2>Error</h2><p>{error.message}</p><button onClick={reset}>Retry</button></div>
)}
>
<App />
</IgniterErrorBoundary>🎯 Context Access
// In loaders
app.page('/dashboard')
.withLoader(async ({ context, client, stores }) => {
const data = await client.dashboard.get.query({ userId: context.user.id });
return { dashboard: data };
})
.build();
// In components
function Profile() {
const ctx = app.useContext();
return <h1>Hello, {ctx.user.name}</h1>;
}File Map
| File | Exports | Purpose |
|---|---|---|
| src/features/{f}/presentation/states/{f}.state.ts | XState | Feature state + actions |
| src/igniter/states.tsx | AppStates | Wire all states via IgniterState.router() |
| src/features/{f}/presentation/triggers/{f}.trigger.ts | XTriggerGroup | Feature trigger group |
| src/igniter/triggers.tsx | AppTriggers | Wire all triggers via IgniterTrigger.create() |
| src/app.igniter.tsx | app | IgniterApp with client, states, triggers, context |
| src/igniter/pages.tsx | router | IgniterRouter with all routes |
| src/main.tsx | App | <IgniterRouterProvider router={router} /> |
API Reference
@igniter-js/app
| Export | Description |
|---|---|
| IgniterApp | Application builder |
| IgniterRouter | Route tree orchestrator |
| IgniterRouterProvider | Router provider (wraps TanStack's RouterProvider) |
| IgniterLayout | Layout route builder |
| IgniterPage | Page route builder |
| IgniterMiddleware | Middleware procedure builder |
| IgniterResponse | Response helpers (.redirect(), .notFound()) |
| IgniterLink | Type-safe navigation link |
| useIgniterNavigate | Programmatic navigation hook |
| IgniterErrorBoundary | Error boundary component |
@igniter-js/app/state
| Export | Description |
|---|---|
| IgniterState | Factory: .create(), .registry(), .router() |
| IgniterStateBuilder | State builder with fluent API |
| IgniterStateInstance | Reactive state class (type only) |
| createRegistryHelper | CRUD helper for registry collections |
@igniter-js/app/trigger
| Export | Description |
|---|---|
| IgniterTrigger | Command palette builder |
| IgniterTriggerGroup | Trigger group builder |
@igniter-js/app/form
| Export | Description |
|---|---|
| useForm | Type-safe form hook (zod + react-hook-form) |
License
MIT
