alepha@docs:~/framework/docs/guides/frontend$
cat 1-react.md | pretty
10 min read
Last commit:

#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:

bash
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 app
  • src/main.browser.ts: browser entry, registers the web module and hydrates

Server entry (main.server.ts):

typescript
 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):

typescript
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.

typescript
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.

typescript
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.

tsx
 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.

typescript
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):

tsx
 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:

typescript
 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:

typescript
 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 started
  • react:action:success: action completed successfully
  • react:action:error: action threw an error
  • react:action:end: always emitted at the end

Global error handling example:

typescript
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.

tsx
 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:

typescript
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, navigator or window during render instead of in an effect;
  • new Date() or Math.random() in a useState initialiser;
  • 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.

tsx
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 and loading: 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:

tsx
 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:

tsx
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:

tsx
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.

tsx
 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, and fallback during 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; resetKeys clears 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.

tsx
 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