You know the drill. You fetch data on the server, pass it through props, and pray it survives hydration. Or you set up Redux with 47 files just to share a user object.
Alepha has a simpler approach: atoms for defining state, useStore for consuming it in React, and automatic SSR hydration. No providers, no reducers, no boilerplate.
Note:
useStoreis part of@alepha/react(core) and works with or without the router.
$atomAn atom is a piece of state with a name and a schema. The schema gives you type safety and validation.
1// atoms/currentProjectAtom.ts 2import { $atom, t } from "alepha"; 3 4export const currentProjectAtom = $atom({ 5 name: "current_project", 6 schema: t.optional(t.object({ 7 id: t.integer(), 8 title: t.text(), 9 description: t.optional(t.text()),10 })),11});
That's your entire state definition. No store setup. No provider wrapping your app.
If you have database entities, you can reuse their schemas directly:
1import { $atom, t } from "alepha";2import { projects } from "../api/entities/projects.ts";3 4// The atom schema matches your database entity5export const currentProjectAtom = $atom({6 name: "current_project",7 schema: t.optional(projects.schema),8});
One schema definition, used everywhere: database, API validation, and frontend state.
For lists, wrap the schema in t.array():
1import { $atom, t } from "alepha";2import { projects } from "../api/entities/projects.ts";3 4export const userProjectsAtom = $atom({5 name: "user_projects",6 schema: t.optional(t.array(projects.schema)),7});
useStoreIn React components, use the useStore hook. It returns a tuple like useState: the current value and a setter function.
1import { useStore } from "@alepha/react"; 2import { userProjectsAtom } from "../atoms/userProjectsAtom.ts"; 3 4const ProjectList = () => { 5 const [projects = []] = useStore(userProjectsAtom); 6 7 return ( 8 <ul> 9 {projects.map(project => (10 <li key={project.id}>{project.title}</li>11 ))}12 </ul>13 );14};
The component re-renders automatically when the atom value changes. No subscriptions to manage.
If the atom might be undefined, provide a fallback:
1const [projects = []] = useStore(userProjectsAtom);2// projects is never undefined, defaults to []
The second element of the tuple is a setter:
1const ThemeToggle = () => {2 const [theme, setTheme] = useStore(themeAtom);3 4 return (5 <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>6 Current: {theme}7 </button>8 );9};
The real power comes when you combine atoms with $page loading. Load data on the server, put it in an atom, and it's instantly available to all components.
1import { $page } from "@alepha/react/router"; 2import { $inject, Alepha } from "alepha"; 3import { $client } from "alepha/server/links"; 4import { currentProjectAtom } from "./atoms/currentProjectAtom.ts"; 5import type { ProjectController } from "./api/ProjectController.ts"; 6 7class AppRouter { 8 alepha = $inject(Alepha); 9 projectApi = $client<ProjectController>();10 11 project = $page({12 path: "/p/:projectId",13 lazy: () => import("./components/ProjectView.tsx"),14 loader: async ({ params }) => {15 // Fetch on server (or client on navigation)16 const project = await this.projectApi.getProjectById({17 params: { id: params.projectId },18 });19 20 // Put it in the atom21 this.alepha.store.set(currentProjectAtom, project);22 23 // Also return for props if needed24 return { project };25 },26 onLeave: () => {27 // Clean up when leaving the page28 this.alepha.store.set(currentProjectAtom, undefined);29 },30 });31}
Now any component under this route can access the project without prop drilling:
1// Deep in the component tree - no props needed2const ProjectHeader = () => {3 const [project] = useStore(currentProjectAtom);4 5 return <h1>{project?.title}</h1>;6};
When the user creates, updates, or deletes something, update the relevant atoms:
1import { useAlepha, useClient, useStore } from "@alepha/react"; 2import { useRouter } from "@alepha/react/router"; 3import { useForm } from "@alepha/react/form"; 4import { userProjectsAtom } from "../atoms/userProjectsAtom.ts"; 5import type { ProjectController } from "../api/ProjectController.ts"; 6 7const ProjectCreate = () => { 8 const alepha = useAlepha(); 9 const client = useClient<ProjectController>();10 const router = useRouter();11 12 const form = useForm({13 schema: t.object({14 title: t.text(),15 description: t.optional(t.text()),16 }),17 handler: async (body) => {18 // Create the project19 const project = await client.createProject({ body });20 21 // Update the atom with the new project22 alepha.store.set(userProjectsAtom, [23 ...(alepha.store.get(userProjectsAtom) || []),24 project,25 ]);26 27 // Navigate to the new project28 await router.go("project", {29 params: { projectId: String(project.id) },30 });31 },32 });33 34 return (35 <form onSubmit={form.onSubmit}>36 {/* form fields */}37 </form>38 );39};
The project list component will re-render automatically with the new project included.
Sometimes you need to read or write atoms outside of React components—in services, event handlers, or page resolution.
alepha.store1// Read2const project = alepha.store.get(currentProjectAtom);3 4// Write5alepha.store.set(currentProjectAtom, newProject);6 7// Read raw key (without atom)8const user = alepha.store.get("alepha.server.request.user");
$useFor class-based services, use the $use primitive for reactive access:
1import { $use } from "alepha";2 3class ThemeService {4 prefs = $use(userPreferencesAtom);5 6 isDarkMode() {7 return this.prefs.theme === "dark";8 }9}
The prefs property updates automatically when the atom changes.
Here's the magic: atoms serialize on the server and hydrate on the client automatically.
1. Server: resolve() fetches data, sets atom
2. Server: React renders with atom value
3. Server: Atom state serialized into HTML
4. Client: Page loads, atom hydrates from HTML
5. Client: React renders with same value - no flash!
No hydration mismatches. No useEffect hacks. No loading spinners for data you already have.
Keep atoms in a dedicated folder, one file per atom:
src/
├── atoms/
│ ├── currentProjectAtom.ts
│ ├── currentTaskAtom.ts
│ ├── userProjectsAtom.ts
│ └── userPreferencesAtom.ts
├── components/
└── api/
Each atom file is simple:
1// atoms/currentTaskAtom.ts2import { $atom, t } from "alepha";3import { tasks } from "../api/entities/tasks.ts";4 5export const currentTaskAtom = $atom({6 name: "current_task",7 schema: t.optional(tasks.schema),8});
| Scenario | Solution |
|---|---|
| Data shared across many components | $atom + useStore |
| Current page/route data | $atom set in loader() |
| User preferences, theme | $atom with default value |
| Authentication state | $atom (Alepha handles this internally) |
| Form input | useForm hook |
| Local UI state (modal open, tab index) | useState |
| Derived/computed values | Just compute in render or use a getter |
1// Define an atom 2const myAtom = $atom({ 3 name: "unique_name", 4 schema: t.object({ ... }), 5 default: { ... }, // optional 6}); 7 8// React component - read & write 9const [value, setValue] = useStore(myAtom);10 11// React component - read only with default12const [value = defaultValue] = useStore(myAtom);13 14// Outside React - read15const value = alepha.store.get(myAtom);16 17// Outside React - write18alepha.store.set(myAtom, newValue);19 20// In services - reactive access21class MyService {22 data = $use(myAtom);23}
State management without the ceremony. Define your shape, use it everywhere, let Alepha handle the plumbing.