@ctxlyr/react
v0.1.0
Published
React state management library for untangling complex optimistic updates & providing app users with legendary error recovery paths.
Maintainers
Readme
Type-Driven State Modeling in React.js with Powerful TypeScript Generics
React state management library for untangling complex optimistic updates & providing app users with legendary error recovery paths.
API Documentation | Code Example
npm install @ctxlyr/reactFeatures @ctxlyr/react
Auto-generate hooks
useStore(Chat)Path-based selectors return slice specific scoped context & action handlers
const { context, action } = useStore(Chat, "Generate.Stream")Fine-grain reactivity with observables
useSelect(context, $("newMessage", "list"))Exhaustive action reducers
Action.when("retry", () => to.slice("Generate.Stream"))Strongly-typed context-aware transitions
to.slice("Generate.Error", {...contextTransitionDelta})DX: Your type declarations flow into each component
import { Chat, useStore } from "@ctxlyr/react/use"Built-in dependency injection through auto-generated
Layerproviders
Describe UI Behavior in a Compact, Readable Structure
Example of defining app behavior using utility type $
import type { $ } from "@ctxlyr/react"
type Store = $.Store<{
Compose: $.Slice<
[
$.Context<{ list: Array<Message> draft: string }>,
$.Action<{ updateDraft: string sendMessage: void }>,
]
>
Generate: $.Slice<
[
$.Context<{
list: Array<Message>
newMessage: Message
responseBuffer: string
}>,
$.SubSlice<{
Stream: $.Slice<[$.OnEntry]>
Error: $.Slice<[$.Action<{ retry: void }>]>
}>,
]
>
}>Instead of piecing together state and behavior from scattered files, you get a comprehensive understanding of your application's flow without overloading your context window.
Each slice defines:
- The guaranteed shape of
contextdata - When an
actioncan mutate state
Less cognitive load → Architect new features without drowning in implementation details
✨ Every Component Binds to an Explicitly Typed Contract
Change how the UI flows & your IDE pinpoints the exact files that must be updated.
Because the entire UI is wired through these path-scoped contracts, any change in the model instantly ripples through your editor.
Path-based selectors return slice specific scoped context & action handlers
const { context, action } = useStore(Chat, "Generate.Stream")
/* └─────┬─────────────────────────────────────────────┘
│
└─ context : { list$: Message[] newMessage$: Message responseBuffer$: string }
action : {} // "Stream" slice has no actions
*/const { context, action } = useStore(Chat, "Generate.Error")
/* └──────┬─────────────────────────────────────────────┘
│
└─ context : { list$: Message[] newMessage$: Message responseBuffer$: string }
action : { retry(): void }
*/By splitting app state into tagged slices, you enable bullet-proof runtime confidence and next-level dev ergonomics.
⚡Observable Fine-grain Reactivity
Under the hood, @ctxlyr/react leverages the incredibly fast Legend-State.
This means you get the maintainability benefits global state through React.useContext without the downsides of wasteful re-renders on each state update.
📖 Usage
$ Type Utilities
The $ namespace provides type utilities for defining your store.
import type { $ } from "@ctxlyr/react"| $ Type Utility | Description |
| --------------- | -------------------------------------------------------------------------------- |
| $.Model<T, U> | Resolved store model, optimized to flow all declared types into React components |
| $.Store<T> | Structure for naming each slice & defining the state tree |
| $.Slice<T> | Single store slice containing context, actions, and optional sub-slices |
| $.Context<T> | Shape of data available within a slice |
| $.Action<T> | Actions that can be dispatched from a slice |
| $.SubSlice<T> | Nested slices that recursively inherit context & actions from parent |
| $.OnEntry | Marker indicating a slice executes logic when entered |
| $.Promise<T> | Promises accessible via Action.onEntry handler |
Store Builder
Runtime utilities for creating and configuring a store.
import { Store } from "@ctxlyr/react"| Store Method | Description |
| ------------------------------------------------- | ------------------------------------------------------------------- |
| type<Model>() | Provides TypeScript inference for the store model |
| type<T>().make(initial, layerService?, actions) | Builds a useable store for generating React hooks |
| initial(slicePath) | Sets the initial slice that the store starts in |
| layer(serviceMap) | Provide services into components through the store hook |
| actions(...handlers) | Exhaustively, builds the action reducer for each defined $.Action |
Action Reducer
Utilities for building exhaustive action handlers that respond to user interactions and slice transitions.
import { Action } from "@ctxlyr/react"| Action Builder | Description |
| ------------------------------------ | --------------------------------------------------- |
| Action.when(actionName, handler) | Handle a specific action dispatch from a React.FC |
| Action.onEntry(slicePath, handler) | Execute side effects when entering a slice |
| Action.exhaustive | Required marker ensuring all actions are handled |
Action Handler Function
| Fn Param | Description |
| --------- | ---------------------------------------------------------- |
| to | Builder for transitioning to other slices |
| context | Observable context for the current slice |
| payload | Data passed from the component when dispatching the action |
| slice | String value of current slice path |
| layer | Object map of provided services |
Action.when("sendMessage", ({ to, payload }) => {
return to.slice("Generate.Stream", { newMessage: payload })
})| onEntry Fn Param | Description |
| ------------------ | ----------------------------------------------- |
| ... | Includes all standard params |
| promise | Object map of promises |
| async (modifier) | Can await a promise created in a previous slice |
Action.onEntry("DocumentUpload.Optimistic", async ({ to, promise }) => {
const doc = await promise.processDocument
return to.slice("DocumentUpload.Confirmed", { doc })
})Transition with to.slice
The to parameter provides strongly-typed utilities for transitioning between slices while maintaining context inheritance and type safety.
// ✅ Valid: newMessage is required for Generate.Stream
to.slice("Generate.Stream", { newMessage: "Hello", responseBuffer: "" })
// ❌ TypeScript Error: missing required newMessage property
to.slice("Generate.Stream", { responseBuffer: "" })[!caution] >
to.sliceMust be returned in order to be applied.
Context Transition Rules
When transitioning between slices, TypeScript automatically determines which context properties are required, optional, or inherited based on the type differences between source and destination slices.
| Transition Type | Source Context | Target Context | Required in to.slice() |
| ----------------------- | --------------------------- | ------------------------------- | ---------------------------- |
| Type Change | { foo: number } | { foo: string } | { foo: string } |
| Property Added | { foo: number } | { foo: number bar: boolean } | { bar: boolean } |
| Required → Optional | { foo: number } | { foo?: number } | Nothing required |
| Optional → Required | { foo?: number } | { foo: number } | { foo: number } |
| Type Narrowing | { foo: string \| number } | { foo: string } | { foo: string } |
| Type Widening | { foo: string } | { foo: string \| number } | Nothing required |
Optional Context Properties
When transitioning to a slice where inherited properties become optional, you can explicitly override them using withOptional.
// Current slice has { abc: string }
// Target slice has { abc?: string, xyz: boolean }
to.slice("TargetSlice", { xyz: true })
// Optionally override inherited value
.withOptional({ abc: "override" })Fine-grain Optimistic UI
When transitioning to a slice defined to expect a promise using $.Promise, use to.withPromise to pass async operations that the target slice will await.
// Target slice expects: $.Promise<{ uploadResult: Promise<Document> }>
to.slice(
"Upload.Optimistic",
{ uploadedAt: new Date() }, // if context required
to.withPromise({
uploadResult: api.upload(file),
}),
)The promise becomes available in the target slice's onEntry handler.
Dispatched Action Payload
Actions receive typed payloads from components based on your $.Action declarations.
/*
$.Action<{
send: { text: string attachments?: File[] }
}>
*/
Action.when("send", ({ payload }) => {
// payload: { text: string attachments?: File[] }
const newMessage = createMessage(payload.text, payload.attachments)
})Update Slice Context
Use $set within action handlers to update the current slice's context without transitioning to a different slice.
Action.when("updateDraft", ({ context, payload }) =>
$set(context.draft$, payload),
)[!caution] >
$setMust be returned in order to be applied.
Access Layer Service Dependencies
Inject services through Store.layer to access them in any action handler via the layer parameter.
/*
Store.layer({
analytics: new Analytics(),
})
*/
Action.when("send", ({ layer }) => {
// Access services in action handlers
layer.analytics.track("message_sent")
})Async State Resolution
When an onEntry handler is marked async, it can await promises created during slice transitions, enabling powerful async state coordination.
/*
$.Promise<{
chargeCard: ReturnType<typeof chargeCard>
}>
*/
Action.onEntry("Payment.Processing", async ({ promise }) => {
const result = await promise.chargeCard
})Usage in React
Export Stores
The Store.type().make & Layer.makeProvider functions transforms your strongly-typed store definitions into a complete React integration layer.
export * from "@ctxlyr/react/hooks"
export { Chat, ChatLayer } from "./chat/store"
export { Auth, AuthLayer } from "./auth/store"Config @ctxlyr/react/use Import Path
For the ultimate developer experience, configure your project to import the generated utilities from a custom path. This technique transforms verbose imports into a clean, project-specific API that feels native to your codebase.
// ❌ Before: Repetitive, fragile imports
import { Chat, useStore } from "../../../app/ctxlyr.ts"
// ✅ After: Clean, maintainable import
import { Chat, useStore } from "@ctxlyr/react/use"Configure TypeScript to resolve the custom import path:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@ctxlyr/react/use": ["./src/app/ctxlyr.ts"]
}
}
}For Vite projects, ensure the bundler uses TypeScript paths at runtime:
import { defineConfig } from "vite"
import tsconfigPaths from "vite-tsconfig-paths"
export default defineConfig({
plugins: [tsconfigPaths()],
})Context Layer Provider
Every store automatically generates a corresponding Layer provider component that initializes the store context and manages the state lifecycle for its component tree.
<ChatLayer context={{ list: [], draft: "" }}>
<ThreadView />
</ChatLayer>The Layer provider serves as the dependency injection boundary for your store, ensuring that all child components have access to the typed context and actions defined in your model.
Store Layer Props
The Layer component accepts props based on your store's type definitions, with TypeScript ensuring you provide all required initial values & promises.
| Layer Prop | Description |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| context | Initial values for the store's context. Optional properties can be omitted or explicitly set. |
| promise | When your store includes $.Promise declarations. Accepts an object mapping promise keys to actual Promise instances that will be available in onEntry handlers for async state coordination. |
useStore Hook
The useStore hook is your primary interface for accessing typed context and actions within components. This auto-generated hook provides slice-specific context access with compile-time guarantees about what data and actions are available.
// Access any slice
const { slice$, context, action, layer } = useStore(Chat)
// Scope to a specific slice path
const { context, action } = useStore(Chat, "Generate.Stream")
// Scope to any sub slice path
const { context, action } = useStore(Chat, "Generate.*")Returned Properties
| Property | Description |
| --------- | ---------------------------------- |
| slice$ | Current slice path as observable |
| context | Slice-specific data as observables |
| action | Available actions for the slice |
| layer | Injected service dependencies |
Runtime Slice Validation
The hook includes runtime validation to ensure components are rendered within the correct slice. This catches routing errors early in development:
// If current slice is "Compose" but component expects "Generate.Stream"
const StreamView: React.FC = () => {
const { context } = useStore(Chat, "Generate.Stream")
// 🚨 Throws: Component rendered in wrong slice: 'Compose' does not match selection 'Generate.Stream'
}useWatch Hook
The useWatch hook bridges the gap between observable state and React's rendering lifecycle. When you pass an observable to useWatch, it automatically subscribes to changes and triggers component re-renders only when that specific value updates.
import { useWatch } from "@ctxlyr/react/use"Observable Syntax
The $ suffix convention signals that a value is observable, providing a visual cue in your code about which values can be watched.
const { slice$, context } = useStore(Chat)
const currentSlice = useWatch(slice$)
const draftMessage = useWatch(context.draft$)useSelect vs useWatch
Both useSelect and useWatch enable fine-grained reactivity, but they serve different ergonomic needs when building components. Understanding when to use each pattern will help you write cleaner, more maintainable React components.
The useSelect Advantage: Bulk Property Access
When your component needs multiple context properties, useSelect dramatically reduces boilerplate by unwrapping all selected observables in a single declaration.
// ❌ Before: Verbose useWatch for each property
const { context } = useStore(Chat, "Generate.Error")
const errorMsg = useWatch(context.errorMsg$)
const responseBuffer = useWatch(context.responseBuffer$)
const retryCount = useWatch(context.retryCount$)
const lastAttempt = useWatch(context.lastAttempt$)
const errorCode = useWatch(context.errorCode$)
const errorStack = useWatch(context.errorStack$)
const userMessage = useWatch(context.userMessage$)
const debugInfo = useWatch(context.debugInfo$)
const timestamp = useWatch(context.timestamp$)
const sessionId = useWatch(context.sessionId$)
// ✅ After: Clean, maintainable with useSelect
const { context } = useStore(Chat, "Generate.Error")
const ctx = useSelect(
context,
$(
"errorMsg",
"responseBuffer",
"retryCount",
"lastAttempt",
"errorCode",
"errorStack",
"userMessage",
"debugInfo",
"timestamp",
"sessionId",
),
)
// All selected properties available on ctx object
return (
<ErrorReport>
<h2>
Error {ctx.errorCode}: {ctx.errorMsg}
</h2>
<p>
Attempt {ctx.retryCount} at {ctx.lastAttempt}
</p>
<details>
<summary>Debug Info (Session: {ctx.sessionId})</summary>
<pre>{ctx.errorStack}</pre>
<code>{JSON.stringify(ctx.debugInfo, null, 2)}</code>
</details>
<output>{ctx.responseBuffer}</output>
</ErrorReport>
)useSelect makes it easy to extend and refactor components.
[!important] The
$utility is required to work withuseSelect
import { $, useSelect } from "@ctxlyr/react/use"When to Use useWatch: Nested Property Access
While useSelect excels at bulk property selection, useWatch shines when you just need to access one property or want to declare as it's own variable.
const name = useWatch(context.profile$.firstName$)