#React Integration
Alepha provides first-class React support with server-side rendering, dependency injection in components, type-safe API calls, and a global state system.
#Project Setup
Scaffold a project with the Alepha CLI:
alepha init my-app
React, SSR and Tailwind are part of every Alepha project - there is no flag to enable them. This generates two entry points:
src/main.server.ts: server entry, registers the API and web modules and starts the appsrc/main.browser.ts: browser entry, registers the web module and hydrates
Server entry (main.server.ts):
1import { Alepha, run } from "alepha"; 2 3import { ApiModule } from "./api/index.ts"; 4import { WebModule } from "./web/index.ts"; 5 6const alepha = Alepha.create(); 7 8alepha.with(ApiModule); 9alepha.with(WebModule);10 11run(alepha);
Browser entry (main.browser.ts):
1import { Alepha, run } from "alepha";2 3import { WebModule } from "./web/index.ts";4 5const alepha = Alepha.create();6alepha.with(WebModule);7 8run(alepha);
The browser entry only registers the modules needed on the client (e.g., routes, UI). Server-only modules like API controllers are excluded.
#Core Hooks
All core React hooks are imported from "alepha/react".
#useAlepha
Returns the current Alepha instance from context. Provides access to the DI container, event system, and store.
1import { useAlepha } from "alepha/react";2 3const MyComponent = () => {4 const alepha = useAlepha();5 // alepha.inject(SomeService)6 // alepha.events.emit(...)7 // alepha.store.get(...)8};
Must be used within an Alepha context (provided automatically by the router or by <AlephaProvider>).
#useInject
Injects a DI service into a React component. The service must be registered with the Alepha instance. Result is memoized.
1import { useInject } from "alepha/react";2 3const Dashboard = () => {4 const analytics = useInject(AnalyticsService);5 // use analytics methods6};
#useClient
Type-safe API calls from React. Connects to server-side controllers via the link system. Works with SSR - on the server, calls are made internally without HTTP.
1import { useAction, useClient } from "alepha/react"; 2import { useState } from "react"; 3 4import type { CountApi } from "./CountApi.ts"; 5 6interface HomeProps { 7 count: number; 8} 9 10const Home = (props: HomeProps) => {11 const [count, setCount] = useState(props.count);12 const countApi = useClient<CountApi>();13 14 const inc = useAction(15 {16 handler: async () => {17 const result = await countApi.inc();18 setCount(result.count);19 },20 },21 [count],22 );23 24 return <button onClick={() => inc.run()}>Click {count}</button>;25};
The type parameter <CountApi> provides full type safety - method names, parameter types, and return types are all inferred from the controller class.
useClient takes the same scope as $client(). A hostname in it (useClient<BillingApi>({ hostname: "https://billing.example.com" })) reaches another Alepha app instead of this one; such a client offers actions only, not $sse streams. See HTTP Links.
#useAction
Manages async operations with loading state, error handling, cancellation, debounce, and polling.
1import { useAction } from "alepha/react";
Returns: { run, refetch, loading, error, cancel, result }
| Property | Type | Description |
|---|---|---|
run |
(...args) => Promise<RunResult> |
Execute the action. Never rejects (see below) |
refetch |
(...args) => Promise<RunResult> |
Re-execute the action, aborting any in-flight request (never dropped by the double-click dedup guard) |
loading |
boolean |
True while executing |
error |
Error | undefined |
Error from last failed execution |
cancel |
() => void |
Cancel debounce timer or abort in-flight |
result |
T | undefined |
Result from last successful execution |
Options:
| Option | Type | Description |
|---|---|---|
handler |
(ctx, ...args) => Promise |
The async function to execute. Receives an ActionContext ({ signal }) FIRST, then the arguments given to run. |
onError |
(error) => void |
Custom error handler. Passing one marks the failure handled: react:action:error still fires, with handled: true, and ActionErrorToaster does not toast it (see below). Errors are never re-thrown by run: they land in its result, in error state and in the event, so fire-and-forget calls can't produce unhandled rejections. |
onSuccess |
(result) => void |
Called after successful execution. |
id |
string |
Identifier for debugging and analytics. |
debounce |
number |
Delay in milliseconds before executing. |
runOnInit |
boolean |
Run when the component mounts, and again whenever the dependency list changes (the new run supersedes one still in flight). |
runEvery |
DurationLike |
Run periodically at the given interval. |
invalidates |
unknown[][] | (result) => unknown[][] |
Query-cache keys to invalidate after success - see Invalidating after a write. |
run resolves a RunResult and never rejects:
| Result | When |
|---|---|
{ ok: true, value } |
The handler returned value. |
{ ok: false, reason: "error", error } |
The handler threw error. |
{ ok: false, reason: "dropped" } |
Refused because a run was already in flight, or a debounced call replaced by a newer one. |
{ ok: false, reason: "aborted" } |
Cancelled by cancel(), an unmount or a newer run. |
{ ok: false, reason: "cancelled" } |
A wrapper hook's user backed out (a declined confirm); useAction itself never produces it. |
So a follow-up branches on it: if ((await save.run()).ok) close();. By default, concurrent executions are prevented, which is the dropped case: disable the controls that trigger the action on loading. refetch and a debounced call abort the in-flight run and replace it instead.
run is not an event handler: write onClick={() => save.run()}, never onClick={save.run}, which would pass the click event as the first argument.
Debounce example (search input):
1const search = useAction( 2 { 3 handler: async (_ctx, query: string) => { 4 return await api.search(query); 5 }, 6 debounce: 300, 7 }, 8 [], 9);10 11// <input onChange={(e) => search.run(e.target.value)} />
Polling example:
1const pollStatus = useAction( 2 { 3 handler: async () => { 4 return await api.getStatus(); 5 }, 6 runEvery: 5000, 7 }, 8 [], 9);10 11// Or with duration tuple:12// runEvery: [30, "seconds"]
AbortController example:
1const fetchData = useAction( 2 { 3 handler: async ({ signal }, url: string) => { 4 const response = await fetch(url, { signal }); 5 return response.json(); 6 }, 7 }, 8 [], 9);10// Automatically cancelled on unmount or when a new request starts
Lifecycle events:
Actions emit events on the Alepha event system:
react:action:begin: action startedreact:action:success: action completed successfullyreact:action:error: action threw an errorreact:action:end: always emitted at the end
Global error handling example:
1alepha.events.on("react:action:error", ({ error, handled }) => {2 if (handled) return;3 toast.danger(error.message);4});
A handled error stays quiet. The error event carries handled: true when the caller already dealt with the failure: the useAction, useQuery or useForm was given an onError, or the error says it is shown in place (shownInPlace), as a form's FormValidationError naming a field does. Every emitter (useAction, useQuery, FormModel, the DataTable) derives it the same way, through ActionErrorPolicy. @alepha/ui's ActionErrorToaster skips a handled error, so onError: () => {} is how a read that can do without its data stays quiet, and an onError that toasts its own message does not get a second toast beside it. The event still fires either way: error reporting, such as the @alepha/lore browser sigil, reads a quiet failure too.
#useEvents
Subscribe to Alepha events inside React components. Subscriptions are automatically cleaned up on unmount.
1import { useEvents } from "alepha/react"; 2 3const StatusBar = () => { 4 useEvents( 5 { 6 "react:transition:begin": (ev) => { 7 console.log("Navigating to:", ev.state.url.pathname); 8 }, 9 "react:action:error": (ev) => {10 console.error("Action failed:", ev.error);11 },12 },13 [],14 );15 16 return <div>...</div>;17};
The second argument is a dependency list (same as useEffect). Events are fully typed based on the Hooks interface. Note that useEvents no-ops outside the browser - an SSR pass registers nothing, so don't rely on it for server-side listeners.
#Hydration mismatches
When the server-rendered HTML and the first client render disagree, React recovers: it re-renders the offending subtree on the client, so the visitor sees a correct page. Nothing throws and no error boundary fires. It is still a defect - the page paints twice, and whatever made the two renders differ is usually a browser-only read during render.
Alepha reports it on react:render:recovered:
1alepha.events.on(2 "react:render:recovered",3 ({ error, componentStack, state }) => {4 console.error(5 `Hydration mismatch on ${state.url.pathname}`,6 componentStack,7 );8 },9);
componentStack names the subtree that mismatched, and React fills it even
in a production build - which is the whole reason to listen here rather than
on window.onerror. React's own default handler calls reportError, so a
production mismatch reaches window.onerror as a minified code with its
arguments blanked: no route, no component, nothing to open.
⚠️ Alepha passes onRecoverableError to hydrateRoot and createRoot,
which replaces that default. These errors therefore no longer reach
window.onerror at all. A crash reporter that listened there has to subscribe
to this event instead - which is what @alepha/lore does, appending
componentStack to the reported stack so two mismatches in different subtrees
are two rows rather than one.
The event is not hydration-only, despite hydration being where it usually comes from: a root keeps the handler for its whole life, so an error a Suspense boundary retried past arrives here too.
The usual causes, in order of how often they turn out to be it:
- reading
localStorage,matchMedia,navigatororwindowduring render instead of in an effect; new Date()orMath.random()in auseStateinitialiser;- markup a browser extension injected into the hydrated subtree.
The fix is the same each time: render what the server rendered, then correct it in an effect after mount.
#Data fetching and cache invalidation
useQuery (from alepha/react) is useAction pre-wired for reads: it runs on mount and whenever its dependency list changes, and returns { data, loading, error, refetch, cancel, isStale }. It works without a cache - pass a handler, get data / loading / error / refetch. Pass a key and it joins a shared cache.
1const { data, loading, isStale } = useQuery(2 {3 key: ["folios", campaignId],4 handler: async ({ signal }) =>5 folioApi.list({ params: { campaignId } }, { request: { signal } }),6 },7 [campaignId],8);
Options (besides handler, which receives { signal }):
| Option | Default | Description |
|---|---|---|
key |
none | Cache key (an array). Without it, nothing is shared or cached. |
staleTime |
0 |
DurationLike. While an entry is fresh, mounting renders it from cache without fetching. |
keepPreviousData |
false |
Keep rendering the previous key's data while a new key loads (pagination, filters). Requires key. |
enabled |
true |
false skips the automatic runs (mount, dependency change, runEvery); refetch() still works. |
initialData |
none | Value of data before the first successful fetch. |
runEvery |
none | Poll at this interval, as in useAction. |
debounce |
none | Debounce delay in milliseconds. |
onSuccess |
none | Called with the resolved value. |
onError |
none | Marks the failure handled: onError: () => {} is how a read that can do without its data stays quiet. |
id |
none | Identifier carried by the react:action:* events. |
loading is already true on the very first render of an enabled query with no initialData, so a skeleton renders without a flash of empty content. isStale is true when a cached entry outlived staleTime and is being revalidated in the background.
A key buys four things:
- Sharing. Two components on the same key read one entry instead of each fetching.
- Deduplication. Two components mounting on the same key in one tick share a single in-flight request - the second joins the first rather than issuing its own.
staleTime. While an entry is fresh, mounting renders it straight from cache with no network call andloading: false.- SSR hydration. The cache is a registered atom, so a server-rendered result arrives in the hydration payload for free.
#Invalidating after a write
This is the part that replaces hand-patching state after a mutation. Declare what a write affects and mounted queries refetch themselves:
1const remove = useAction( 2 { 3 handler: async (_ctx, id: string) => folioApi.delete({ params: { id } }), 4 invalidates: [ 5 ["folios", campaignId], 6 ["folioTags", campaignId], 7 ], 8 }, 9 [campaignId],10);
Keys are arrays and matching is by prefix, so ["folios"] drops ["folios", 1] and ["folios", 2] without the mutation needing to know which campaigns were queried. It will not touch ["folios-archive"].
Pass a function when the keys depend on the result:
1invalidates: (created) => [["folios", created.campaignId]],
#Imperative access
When the trigger is not a useAction - a websocket message, a router event, an optimistic write - use useQueryClient:
1const queries = useQueryClient();2 3queries.invalidate(["folios", campaignId]);4queries.setData(["folio", id], (previous) => ({ ...previous, pinned: true }));5queries.get(["folio", id]); // read without subscribing6queries.clear(); // on logout
#useQuery or $page.loader?
Both fetch. The dividing line is whether the route can render without the data:
$page.loader: the page is meaningless without it (the folio being viewed). It runs before render, so there is no loading state to design, and it participates in SSR.useQuery: a component owns the data and can render a skeleton while it arrives (a sidebar, a backlinks panel, a tag list). It is also the right choice for anything a mutation should be able to invalidate.
Mixing them is normal: load the subject in the route, query its satellites in components.
#ClientOnly and ErrorBoundary
alepha/react ships three components. ClientOnly and ErrorBoundary are shown here; the third, <Personal>, keeps the visitor-specific part of a page (a user menu) out of a cached render, see Page Cache.
1import { ClientOnly, ErrorBoundary } from "alepha/react"; 2 3const Clock = () => ( 4 <ErrorBoundary 5 fallback={(error, reset) => ( 6 <button onClick={reset}>{error.message}</button> 7 )} 8 > 9 <ClientOnly fallback={<span>--:--</span>}>10 {new Date().toLocaleTimeString()}11 </ClientOnly>12 </ErrorBoundary>13);
<ClientOnly fallback? disabled?>renders its children only after hydration, andfallbackduring the server render and the hydration pass. Use it for browser-only reads (see Hydration mismatches).<ErrorBoundary fallback onError? resetKeys?>catches render errors below it.fallback(error, reset)is required;resetKeysclears a caught error when any of the values changes, without remounting the children. Every page is already wrapped in one by the router.
#AlephaProvider
Only needed if you are not using the Alepha Router (e.g., in Expo or Next.js integrations). When using $page and the router, the context is provided automatically.
1import { AlephaProvider } from "alepha/react"; 2 3const App = () => { 4 return ( 5 <AlephaProvider 6 onLoading={() => <div>Loading...</div>} 7 onError={(error) => <div>Error: {error.message}</div>} 8 > 9 <MyApp />10 </AlephaProvider>11 );12};
AlephaProvider creates an Alepha instance, calls start(), and provides the instance via React context. Props:
| Prop | Type | Description |
|---|---|---|
children |
ReactNode |
Application content |
onLoading |
() => ReactNode |
Rendered while Alepha is starting |
onError |
(error: Error) => ReactNode |
Rendered if start fails |