alepha@docs:~/docs/framework/guides/frontend$
cat 1-react.md | pretty
6 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"; 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):

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

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"; 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.

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

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

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 (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 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 }) => {2  toast.danger(error.message);3});

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

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

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);

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

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