react
Every component in a real app needs something from outside itself: the HTTP client, the signed-in user, a feature flag, the theme. @kitr/react puts the kitr app object in React context, so any component can read a service or a config file with one hook, without importing singletons, prop-drilling or writing a context of its own. It is built on React and adds KitrProvider, KitrBoot and three hooks: useApp, useService and useConfig.
It runs on the client, and on the server during server rendering. A server-only project (the server kit) has no React at all; use server and reach services through app.container there.
This guide follows Acme's billing app at app.acme.com: Ada adds a billing service that loads invoices, and every component reaches it through the app.
Install
Every UI kit (blank, app, fullstack, saas, site, realtime, desktop, mobile) includes React, and the web, desktop and mobile platforms add it through the router, which requires it. You only run this in a project that has none:
kitr add reactFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
What kitr add does:
- Installs
@kitr/react,reactandreact-dom, and as dev dependencies@vitejs/plugin-react,@types/reactand@types/react-dom. - Adds
'@kitr/react'topackagesinkitrrc.ts. React registers no provider, soprovidersis unchanged. - Writes
app/env.ts, which declares one public variable,VITE_APP_NAME, defaulting to your package name. - Writes
app/app.ts, which creates the app from the providers inkitrrc.ts, the files inconfig/andapp/env.ts. - Adds no config file, no variables to
.env.example, no scripts and noAGENTS.mdsection.
kitr add never overwrites a file that exists, so an app/env.ts or app/app.ts you already have is kept. kitr add react asks no questions. It accepts the usual flags: --no-install skips the package manager and --dry-run prints what would change.
kitr add react --dry-runFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
kitr remove react removes the packages and the kitrrc.ts entry. It leaves app/env.ts and app/app.ts in place, because the rest of the app imports them.
Configure
@kitr/react has no config file. What it needs is the app object, which kitr add react writes to app/app.ts:
// app/app.ts
import { createApp } from 'kitr/app'
import { config } from 'virtual:kitr/config'
import { providers } from 'virtual:kitr/providers'
import { env } from './env'
/** One app object: providers from kitrrc.ts, config from config/*.ts, both as static imports. */
export const app = createApp({ providers, config, env })
virtual:kitr/providers is the providers list from kitrrc.ts, and virtual:kitr/config holds every config/<name>.ts, keyed by file name. The kitr() Vite plugin generates both.
app/env.ts is the environment file it passes in:
// app/env.ts
import { createEnv, env as e } from 'kitr/env'
/** Validated at startup. Public vars (VITE_*) ship to the browser; server vars throw if read there. */
export const env = createEnv({
public: {
VITE_APP_NAME: e.string({ default: 'acme-billing' }),
},
server: {},
})
You hand the app to React through the props of KitrProvider or KitrBoot:
| Option | Default | Description |
|---|---|---|
app |
required | The KitrApp from app/app.ts. Both components put it in context and render every provider's wrap() around children. |
children |
required | The tree that can call useApp, useService and useConfig. |
fallback |
null |
KitrBoot only. What renders while the app boots. |
createApp from kitr/app (also re-exported from @kitr/react) takes these options:
| Option | Default | Description |
|---|---|---|
providers |
[] |
Providers, or lazy imports of them, in kitrrc.ts order. |
config |
{} |
Per-package config keyed by package name, as in config/<name>.ts. |
env |
{} |
The validated env object from createEnv. Exposed as app.env. |
environment |
detected | 'client', 'server' or 'test'. 'test' under Vitest, 'client' when window and document exist, otherwise 'server'. |
mode |
detected | 'development', 'production' or 'test', from Vite's import.meta.env.MODE or NODE_ENV. |
platform |
import.meta.env.KITR_PLATFORM |
The platform name from kitrrc.ts this bundle runs as, such as site. |
Read a service in your first component
With the router installed there is nothing to wire: createKitrRouter renders KitrProvider around every page. A component reads a service with useService and a config file with useConfig:
// app/components/invoices/invoice-count.tsx
import { useConfig, useService } from '@kitr/react'
import { useEffect, useState } from 'react'
export function InvoiceCount() {
const http = useService('http') // the ky instance from @kitr/http
const theme = useConfig('theme') // config/theme.ts
const [count, setCount] = useState<number | null>(null)
useEffect(() => {
http
.get('invoices')
.json<{ id: string }[]>()
.then((invoices) => setCount(invoices.length))
}, [http])
return (
<p>
{count === null ? 'Loading invoices…' : `${count} open invoices`} (theme: {theme.default})
</p>
)
}
Use it in any route:
// app/routes.ts
import { InvoiceCount } from '#app/components/invoices/invoice-count'
r.page('/invoices', {
<main>
<h1>Invoices</h1>
<InvoiceCount />
</main>
),
lazy: () => import('./pages/invoices'),
})
// app/pages/invoices.tsx
export default () => (
Open /invoices and the page shows 3 open invoices (theme: system). Both hooks are typed: useService('http') returns ky's KyInstance because http declares that binding, and useConfig('theme') returns ThemeConfig because theme declares that config. A name no installed package declares returns unknown.
For data, prefer query over useEffect; this example only shows where the service comes from.
Mount the app without the router
On the client, when you render React yourself (a widget, a micro-frontend, a test page), wrap the root in KitrBoot:
// app/main.tsx
import { KitrBoot } from '@kitr/react'
import { createRoot } from 'react-dom/client'
import { app } from './app'
import { InvoiceCount } from './components/invoices/invoice-count'
createRoot(document.getElementById('root')!).render(
<KitrBoot app={app} fallback={<p>Starting Acme Billing…</p>}>
<InvoiceCount />
</KitrBoot>,
)
KitrBoot calls app.boot(), which runs every provider's register and then its boot. It renders fallback until that finishes and then renders KitrProvider. If the app has already booted, it renders the children on the first render and never shows the fallback.
If boot fails, KitrBoot rethrows the error during render, so the nearest React error boundary catches it.
Boot before rendering
When you can await, boot first and render KitrProvider directly. Nothing suspends and the first render is complete:
// app/main.tsx
import { KitrProvider } from '@kitr/react'
import { createRoot } from 'react-dom/client'
import { app } from './app'
import { InvoiceCount } from './components/invoices/invoice-count'
await app.boot()
createRoot(document.getElementById('root')!).render(
<KitrProvider app={app}>
<InvoiceCount />
</KitrProvider>,
)
This is what app/client.tsx does in a router project: it boots the app, then hydrates the server's HTML, so the first client render matches what the server sent.
// app/client.tsx
import { StartClient } from '@tanstack/react-start/client'
import { StrictMode, startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { app } from './app'
/** Start's client entry, with the kitr app booted first so providers are ready when the tree hydrates. */
app.boot().then(() => {
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<StartClient />
</StrictMode>,
)
})
})
app.boot() is idempotent: every caller shares one boot, so calling it from client.tsx, the router and a test never runs a provider twice.
Render before boot with Suspense
KitrProvider also accepts an app that has not booted. It suspends on the boot promise, so the nearest <Suspense> boundary shows its fallback until every provider is ready. TanStack Start relies on this, because it builds the router synchronously before anything has awaited app.boot().
// app/main.tsx
import { KitrProvider } from '@kitr/react'
import { Suspense } from 'react'
import { createRoot } from 'react-dom/client'
import { app } from './app'
import { InvoiceCount } from './components/invoices/invoice-count'
createRoot(document.getElementById('root')!).render(
<Suspense fallback={<p>Starting Acme Billing…</p>}>
<KitrProvider app={app}>
<InvoiceCount />
</KitrProvider>
</Suspense>,
)
Without a <Suspense> boundary above it, React has nowhere to show the wait and throws. Use KitrBoot when you do not want to place a boundary yourself.
| You have | Use |
|---|---|
| The router | Nothing. createKitrRouter renders KitrProvider. |
Your own root, and you can await |
await app.boot(), then KitrProvider. |
Your own root, and you cannot await |
KitrBoot with a fallback. |
A <Suspense> boundary already in place |
KitrProvider with an unbooted app. |
Read the app itself
useApp() returns the whole app. Reach for it when you need something that is not a single service or config file:
// app/components/environment-badge.tsx
import { useApp } from '@kitr/react'
export function EnvironmentBadge() {
const app = useApp()
if (app.isProduction) return null
return (
<span>
{app.mode} on {app.platform ?? 'unknown platform'} ({app.environment})
</span>
)
}
// In `kitr dev site`: "development on site (client)"
| Property | What it is |
|---|---|
app.container |
The service container: use(key), has(key), make(key), keys(). |
app.config |
Every config/<name>.ts: get(key, fallback?), has(key). |
app.env |
The object from app/env.ts, untyped. Import env from #app/env for types. |
app.mode |
'development', 'production' or 'test'. |
app.environment |
'client', 'server' or 'test'. |
app.platform |
The platform name from kitrrc.ts, such as site, or undefined. |
app.isClient, app.isServer, app.isDevelopment, app.isProduction |
Shorthands for the four checks above. |
app.status |
'created', 'registered', 'booted', 'ready' or 'shutdown'. |
For typed environment variables, import the env object directly rather than reading app.env:
// app/components/app-title.tsx
import { env } from '#app/env'
export function AppTitle() {
return <title>{env.VITE_APP_NAME}</title>
}
Read config in a component
useConfig(name) returns what config/<name>.ts exports by default:
// app/components/theme-color.tsx
import { useConfig } from '@kitr/react'
export function ThemeColorSwatch() {
const { colors } = useConfig('theme')
return <span style={{ background: colors?.light }} aria-label="Light theme color" />
}
When the file does not exist, useConfig throws [kitr] Missing config "theme". Create config/theme.ts or pass it to createApp(). For optional config, read it through the app with a fallback instead:
// app/components/billing-banner.tsx
import { useApp } from '@kitr/react'
export function BillingBanner() {
const billing = useApp().config.get('billing', { currency: 'usd' })
return <p>Invoices are billed in {billing.currency.toUpperCase()}.</p>
}
Config is static: it is read once when app/app.ts runs. A component that reads it never re-renders because of it.
Register your own service
Your own services go in the same container as the packages' services. Ada's billing client is a plain class:
// app/billing/client.ts
export interface Invoice {
id: string
team: string
total: number
status: 'open' | 'paid'
}
export class BillingClient {
constructor(private readonly baseUrl: string) {}
async openInvoices(team: string): Promise<Invoice[]> {
const res = await fetch(`${this.baseUrl}/teams/${team}/invoices?status=open`)
return (await res.json()) as Invoice[]
}
}
A provider binds it during register:
// app/providers/billing-provider.ts
import { defineProvider } from 'kitr/app'
import { BillingClient } from '#app/billing/client'
declare module '@kitr/core' {
interface ContainerBindings {
billing: BillingClient
}
interface ConfigRegistry {
billing: { currency: 'usd' | 'eur' }
}
}
export default defineProvider({
name: 'billing',
register(app) {
app.container.singleton('billing', () => new BillingClient('https://api.acme.com'))
},
})
The ConfigRegistry entry types config/billing.ts for useConfig('billing') and app.config.get('billing'), the same way packages type theirs.
List it in kitrrc.ts, after the package providers it depends on:
// kitrrc.ts
import { defineKitrConfig } from 'kitr/app'
import { web } from '@kitr/web'
export default defineKitrConfig({
packages: ['@kitr/react', '@kitr/router', '@kitr/web'],
providers: [
() => import('@kitr/web/provider'),
() => import('#app/providers/billing-provider'),
],
platforms: { site: web({ mode: 'spa' }) },
})
Every component can now use it, typed:
// app/components/invoices/open-invoices.tsx
import { useService } from '@kitr/react'
import { useEffect, useState } from 'react'
import type { Invoice } from '#app/billing/client'
export function OpenInvoices({ team }: { team: string }) {
const billing = useService('billing') // BillingClient
const [invoices, setInvoices] = useState<Invoice[]>([])
useEffect(() => {
billing.openInvoices(team).then(setInvoices)
}, [billing, team])
return (
<ul>
{invoices.map((invoice) => (
<li key={invoice.id}>
{invoice.id}: ${invoice.total}
</li>
))}
</ul>
)
}
kitr make:provider billing writes the provider file for you, at app/providers/billing-provider.ts, with register, boot and a commented wrap, and lists it in providers in kitrrc.ts.
Augment '@kitr/core', the module that declares ContainerBindings. Don't augment 'kitr/app': it re-exports @kitr/core with export *, and TypeScript does not merge an interface through a re-export, so useService('billing') stays unknown.
Bind async services
useService is synchronous. A factory that returns a promise works because app.boot() resolves every binding before the first render, so by the time a component asks, the instance is cached:
// app/providers/billing-provider.ts
import { defineProvider } from 'kitr/app'
import { BillingClient } from '#app/billing/client'
export default defineProvider({
name: 'billing',
register(app) {
app.container.singleton('billing', async () => {
const res = await fetch('https://app.acme.com/billing-config.json')
const { apiUrl } = (await res.json()) as { apiUrl: string }
return new BillingClient(apiUrl)
})
},
})
If a component reads an async binding that is still resolving, useService throws [kitr] Binding "billing" has an async factory; await container.make('billing') before use('billing'). That only happens when you render with an app that has not finished booting and you bound the service after boot. Bind in register, and boot before rendering.
Wrap the tree from a provider
A provider can put its own React context around the whole app with wrap(). KitrProvider composes every provider's wrap in kitrrc.ts order: the first provider is the outermost. That is how the query client, theme, auth session, translations and error boundary all reach your components while the root stays one line.
// app/providers/team-provider.tsx
import { defineProvider } from 'kitr/app'
import { createContext, type ReactNode, useContext } from 'react'
const TeamContext = createContext<{ team: string }>({ team: 'acme' })
export const useTeam = () => useContext(TeamContext)
export default defineProvider({
name: 'team',
wrap: (children) => (
<TeamContext.Provider value={{ team: 'acme' }}>{children as ReactNode}</TeamContext.Provider>
),
})
With providers: [query, theme, team], the rendered tree is <QueryClientProvider><ThemeProvider><TeamContext.Provider>{children}. wrap receives the app as its second argument, so it can read config and services:
// app/providers/team-provider.tsx
import { defineProvider } from 'kitr/app'
import { createContext, type ReactNode, useContext } from 'react'
const TeamContext = createContext<{ team: string }>({ team: 'acme' })
export const useTeam = () => useContext(TeamContext)
export default defineProvider({
name: 'team',
wrap: (children, app) => (
<TeamContext.Provider value={{ team: app.config.get('team', { slug: 'acme' }).slug }}>
{children as ReactNode}
</TeamContext.Provider>
),
})
Here config/team.ts is optional: without it, app.config.get returns the fallback. Declare team in ConfigRegistry as shown for billing above to type it.
wrap runs on every render of KitrProvider's children, so keep it to rendering context providers. Do I/O in boot.
Don't add a second <QueryClientProvider> or <ThemeProvider> in your root component. The packages' providers already wrap the tree; a second one shadows theirs with a different instance.
Reach the app outside components
Loaders, beforeLoad guards and plain modules are not React components, so they cannot call hooks. In a route, read the app from the router context:
// app/routes.ts
r.page('/teams/$team/invoices', {
loader: ({ context, params }) => context.app.container.use('billing').openInvoices(params.team),
lazy: () => import('./pages/teams/team/invoices'),
})
// app/pages/teams/team/invoices.tsx
import { getRouteApi } from '@kitr/router'
const route = getRouteApi('/teams/$team/invoices')
export default function TeamInvoices() {
const invoices = route.useLoaderData()
return <p>{invoices.length} open invoices for this team</p>
}
On the server, createKitrRouter forks the app per request, and context.app is that request's fork. Don't import app from #app/app inside a loader: on the server it is the shared app, and a request-scoped service such as the query cache would leak between visitors.
Outside routes, in client-only code such as an event handler set up at startup, import the app:
// app/billing/refresh.ts
import { app } from '#app/app'
export async function refreshOpenInvoices(team: string) {
const billing = await app.container.make('billing')
return billing.openInvoices(team)
}
make() awaits an async factory; use() does not. Both return the same cached instance.
Commands
| Command | What it does |
|---|---|
kitr make:component <name> |
Writes a component to app/components/<kebab-name>.tsx (or your directories.components). --force overwrites an existing file. |
kitr make:hook <name> |
Writes a hook to app/hooks/use-<kebab-name>.ts. --force overwrites an existing file. |
kitr eject stub component, kitr eject stub hook |
Copies the generator's template to stubs/<kind>.ts, so make:* writes your own starting point. |
kitr make:component invoice-summary writes app/components/invoice-summary.tsx:
// app/components/invoice-summary.tsx
import type { ReactNode } from 'react'
export interface InvoiceSummaryProps {
children?: ReactNode
className?: string
}
export function InvoiceSummary({ children, className }: InvoiceSummaryProps) {
return <div className={className}>{children}</div>
}
kitr make:hook order-filter writes app/hooks/use-order-filter.ts, a toggle you rename and grow:
// app/hooks/use-order-filter.ts
import { useCallback, useState } from 'react'
export function useOrderFilter(initial = false) {
const [value, setValue] = useState(initial)
const toggle = useCallback(() => setValue((v) => !v), [])
return { value, setValue, toggle }
}
React adds no kitr doctor checks.
Errors
These are thrown during render, so React's nearest error boundary receives them. With the router, that is the route's error screen.
| Error | When | What to do |
|---|---|---|
[@kitr/react] useApp() must be used inside <KitrProvider>. |
useApp, useService or useConfig ran in a component outside KitrProvider. |
Render the component under the router, KitrProvider or KitrBoot. In tests, use renderWithKitr. |
[kitr] No binding named "billing". Did you add the package that provides it? |
useService(key) for a key no provider registered. |
kitr add the package, or list your provider in kitrrc.ts. |
[kitr] Binding "billing" has an async factory; await container.make('billing') before use('billing'). |
The binding's factory returned a promise that has not resolved yet. | Boot before rendering, or await app.container.make(key) first. |
[kitr] Missing config "billing". Create config/billing.ts or pass it to createApp(). |
useConfig(key) for a config file that does not exist. |
Create the file, or use useApp().config.get(key, fallback). |
| The provider's own error | A provider's register or boot threw inside KitrBoot. |
Fix the provider. KitrBoot rethrows it so an error boundary shows it. |
Testing
renderWithKitr from testing renders a component inside KitrProvider with a test app. createTestApp creates and boots an app with environment: 'test', so you pass it the same providers and config your component needs, or a stand-in service:
// app/components/invoices/open-invoices.test.tsx
import { defineProvider } from 'kitr/app'
import { createTestApp, renderWithKitr } from '@kitr/testing/react'
import { describe, expect, it } from 'vitest'
import type { BillingClient } from '#app/billing/client'
import { OpenInvoices } from './open-invoices'
describe('OpenInvoices', () => {
it('lists the team’s open invoices from the billing service', async () => {
const billing = {
openInvoices: async (team: string) => [
{ id: 'inv_1', team, total: 120, status: 'open' as const },
{ id: 'inv_2', team, total: 80, status: 'open' as const },
],
} as unknown as BillingClient
const app = await createTestApp({
providers: [
defineProvider({
name: 'billing',
register: (a) => void a.container.value('billing', billing),
}),
],
})
const screen = renderWithKitr(<OpenInvoices team="acme" />, { app })
expect(await screen.findByText('inv_1: $120')).toBeTruthy()
expect(screen.getByText('inv_2: $80')).toBeTruthy()
})
})
container.value(key, instance) binds an object you built, which is the simplest way to fake a service. Config works the same way: createTestApp({ config: { billing: { currency: 'eur' } } }).
To test a provider's wrap, boot an app with it and render under KitrProvider from @kitr/react:
// app/providers/team-provider.test.tsx
import { KitrProvider } from '@kitr/react'
import { createTestApp } from '@kitr/testing'
import { render, screen } from '@testing-library/react'
import { expect, it } from 'vitest'
import teamProvider, { useTeam } from './team-provider'
it('puts the team in context', async () => {
const app = await createTestApp({ providers: [teamProvider] })
function TeamName() {
return <p>{useTeam().team}</p>
}
render(
<KitrProvider app={app}>
<TeamName />
</KitrProvider>,
)
expect(screen.getByText('acme')).toBeTruthy()
})
A component that calls useApp with nothing around it throws the inside <KitrProvider> error, which is what you want a test to catch:
// app/components/environment-badge.test.tsx
import { render } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import { EnvironmentBadge } from './environment-badge'
it('needs the app in context', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(<EnvironmentBadge />)).toThrow(/inside <KitrProvider>/)
})
Next steps
- API reference: every export of
@kitr/reactwith its signature. - Router: pages, loaders and guards, with
KitrProvideralready in place. - Testing:
createTestApp,renderWithKitrand the API test client. - Configuration:
kitrrc.ts,config/andapp/env.ts. - React documentation: Suspense, context and error boundaries.