#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"; 2import { ApiModule } from "./api/index.ts"; 3import { WebModule } from "./web/index.ts"; 4 5const alepha = Alepha.create(); 6 7alepha.with(ApiModule); 8alepha.with(WebModule); 9 10run(alepha);
Browser entry (main.browser.ts):
1import { Alepha, run } from "alepha";2import { WebModule } from "./web/index.ts";3 4const alepha = Alepha.create();5alepha.with(WebModule);6 7run(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"; 3import type { CountApi } from "./CountApi.ts"; 4 5interface HomeProps { 6 count: number; 7} 8 9const Home = (props: HomeProps) => {10 const [count, setCount] = useState(props.count);11 const countApi = useClient<CountApi>();12 13 const inc = useAction(14 {15 handler: async () => {16 const result = await countApi.inc();17 setCount(result.count);18 },19 },20 [count],21 );22 23 return <button onClick={inc.run}>Click {count}</button>;24};
The type parameter <CountApi> provides full type safety - method names, parameter types, and return types are all inferred from the controller class.
#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 |
Execute the action |
refetch |
() => Promise |
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 |
(...args, ctx) => Promise |
The async function to execute. Receives an ActionContext with an AbortSignal as the last argument. |
onError |
(error) => void |
Custom error handler. Errors are never re-thrown by run - they land in error state and the react:action:error 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 once when the component mounts. |
runEvery |
DurationLike |
Run periodically at the given interval. |
invalidates |
string[] |
Query-cache keys to invalidate after success - see Invalidating after a write. |
By default, concurrent executions are prevented - calling run while already executing is a no-op.
Debounce example (search input):
1const search = useAction( 2 { 3 handler: async (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 (url: string, { signal }: { signal: AbortSignal }) => { 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 }) => {2 toast.danger(error.message);3});
#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.
#Data fetching and cache invalidation
useQuery 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);
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 (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.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.
#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 |