Skip to content

React ​

React bindings for LogosDX. Each factory takes an engine instance, infers all generics, and returns a [Provider, useHook] tuple you can rename to whatever fits your domain. Install only the peer dependencies you actually use — they're all optional.

Installation ​

bash
npm install @logosdx/react
bash
yarn add @logosdx/react
bash
pnpm add @logosdx/react

Then install the engine packages you need:

bash
pnpm add @logosdx/observer @logosdx/fetch @logosdx/storage @logosdx/localize @logosdx/state-machine

Subpath Imports ​

Each binding is available as a standalone subpath export. This means peer dependencies are only required for the bindings you actually import:

typescript
// Only requires @logosdx/storage as a peer dep
import { createStorageContext } from '@logosdx/react/storage';

// Only requires @logosdx/state-machine as a peer dep
import { useStateMachine } from '@logosdx/react/state-machine';

// Only requires @logosdx/fetch (+ optional @logosdx/observer) as peer deps
import { createApiHooks, useQuery, useMutation, useAsync } from '@logosdx/react/api';

// Barrel import still works — requires all used peer deps
import { createStorageContext, useStateMachine } from '@logosdx/react';

Quick Start ​

typescript
// setup.ts — run once, types inferred from instances
import { ObserverEngine } from '@logosdx/observer';
import { FetchEngine } from '@logosdx/fetch';
import { StorageAdapter, WebStorageDriver } from '@logosdx/storage';
import { LocaleManager } from '@logosdx/localize';
import { StateMachine } from '@logosdx/state-machine';
import {
    createObserverContext,
    createFetchContext,
    createStorageContext,
    createLocalizeContext,
    createStateMachineContext,
    composeProviders,
} from '@logosdx/react';

// Create your engine instances
const observer = new ObserverEngine<AppEvents>();
const api = new FetchEngine({ baseUrl: 'https://api.example.com' });
const storage = new StorageAdapter<AppStore>({
    driver: new WebStorageDriver(localStorage),
    prefix: 'myapp',
});
const i18n = new LocaleManager({ current: 'en', fallback: 'en', locales });
const game = new StateMachine<GameContext, GameEvents, GameStates>({ ... });

// Create context + hook pairs — rename freely
export const [AppObserver, useAppObserver] = createObserverContext(observer);
export const [ApiFetch, useApiFetch] = createFetchContext(api);
export const [AppStorage, useAppStorage] = createStorageContext(storage);
export const [AppLocale, useAppLocale] = createLocalizeContext(i18n);
export const [GameProvider, useGame] = createStateMachineContext(game);

// Compose into a single wrapper — no nesting required
export const Providers = composeProviders(
    AppObserver,
    ApiFetch,
    AppStorage,
    AppLocale,
    GameProvider,
);
tsx
// App.tsx — one wrapper, done
<Providers>
    <App />
</Providers>

Pattern ​

Every factory follows the same pattern:

  1. Pass an engine instance — TypeScript infers all generics
  2. Get back [Provider, useHook] — rename to match your domain
  3. Compose providers with composeProviders or nest them manually
  4. Call the hook in components to get a typed API

The provider captures the instance at creation time. No props needed.

Observer ​

createObserverContext binds an ObserverEngine to React's lifecycle. Subscriptions are managed via useEffect — they clean up automatically on unmount or when the callback identity changes.

typescript
import { createObserverContext } from '@logosdx/react';
import { ObserverEngine } from '@logosdx/observer';

interface AppEvents {
    'user.login': { userId: string; token: string };
    'user.logout': { userId: string };
    'notification': { message: string };
}

const engine = new ObserverEngine<AppEvents>();
export const [AppObserver, useAppObserver] = createObserverContext(engine);

on(event, callback) ​

Subscribes to an event. Re-subscribes when the callback identity changes, so wrap handlers with useCallback to keep them stable.

typescript
function UserStatus() {

    const { on } = useAppObserver();

    const handler = useCallback((data) => {
        console.log('logged in:', data.userId);
    }, []);

    on('user.login', handler);

    return <div>...</div>;
}

once(event, callback) ​

One-shot listener. Fires once and cleans up.

typescript
const { once } = useAppObserver();

const initHandler = useCallback((cfg) => {
    console.log('app initialized:', cfg);
}, []);

once('app.init', initHandler);

oncePromise(event) ​

Reactive one-shot — no callback needed. Returns a [waiting, data, cancel] tuple that updates when the event fires.

typescript
const { oncePromise } = useAppObserver();

const [waiting, data, cancel] = oncePromise('notification');

if (waiting) return <Spinner />;

return <p>{data?.message}</p>;

emit(event, data) and emitFactory(event) ​

Fire events directly or get a memoized emitter for a specific event.

typescript
const { emit, emitFactory } = useAppObserver();

// Direct
emit('user.logout', { userId: '123' });

// Memoized — stable reference, safe for onClick
const logout = emitFactory('user.logout');

return <button onClick={() => logout({ userId: '123' })}>Log out</button>;

instance ​

Raw engine access for imperative use cases.

typescript
const { instance } = useAppObserver();

// Use in event handlers where you need the full engine API
const handleExport = async () => {
    instance.emit('export.started', { format: 'csv' });
};

Fetch ​

createFetchContext provides auto-fetching queries and on-demand mutations. Queries fire on mount and re-fetch when the path or options change. Mutations start idle and fire when triggered.

typescript
import { createFetchContext } from '@logosdx/react';
import { FetchEngine } from '@logosdx/fetch';

const api = new FetchEngine({
    baseUrl: 'https://rainbow-loans.com',
    retry: { maxAttempts: 3 },
});

export const [ApiFetch, useApiFetch] = createFetchContext(api);

Queries — get(path, options?) ​

Auto-fetches on mount. Returns { data, loading, failure, refetch, cancel }. data is the unwrapped T. failure is one signal for "did it fail" — narrow on kind to see which channel:

  • kind: 'transport' — no response exists at all (abort, timeout, connection lost). failure.error is a FetchError with .isCancelled(), .isTimeout(), .isConnectionLost().
  • kind: 'http' — the server answered outside 2xx. failure.response is the resolved, ok: false FetchResponse (status, headers, data).
typescript
function UserList() {

    const { get } = useApiFetch();

    const { data, loading, failure, refetch } = get<User[]>('/users');

    if (loading) return <Spinner />;

    if (failure?.kind === 'transport') return <Error message={failure.error.message} />;
    if (failure?.kind === 'http') return <Error status={failure.response.status} />;

    return (
        <ul>
            {data?.map(u => <li key={u.id}>{u.name}</li>)}
        </ul>
    );
}

With typed response headers:

typescript
const { failure } = get<Post[], { 'x-total': string }>('/posts');
// failure?.kind === 'http' narrows failure.response.headers['x-total'] to string

Mutations — post, put, del, patch ​

Start idle. Returns { data, loading, failure, mutate, reset, cancel, called }. mutate() never rejects — it resolves Promise<T | undefined>: the parsed body on success, undefined on any failure (transport or HTTP). Read failure for why.

typescript
function CreateComment() {

    const { post, del } = useApiFetch();

    const { mutate: submit, loading: isSubmitting, data: result, failure: submitFailure } =
        post<Comment>('/comments');

    const { mutate: remove, loading: isRemoving, failure: removeFailure } =
        del<void>('/comments/123');

    return (
        <form onSubmit={async () => {
            const comment = await submit({ text: 'Hello' });
            if (comment) navigate(`/comments/${comment.id}`);
        }}>
            <button disabled={isSubmitting}>
                {isSubmitting ? 'Sending...' : 'Submit'}
            </button>
            {result && <p>Created: {result.id}</p>}
            {submitFailure?.kind === 'http' && <p>Failed ({submitFailure.response.status})</p>}
            {submitFailure?.kind === 'transport' && <p>Failed: {submitFailure.error.message}</p>}
        </form>
    );
}

Escape Hatch — instance ​

For imperative one-off requests in event handlers:

typescript
import { attempt } from '@logosdx/utils';

function ExportButton() {

    const { instance } = useApiFetch();

    const handleExport = async () => {
        const [res, err] = await attempt(() => instance.get('/export'));
        if (err) return console.error(err);
        if (!res.ok) return console.error('Export failed:', res.status);
        downloadBlob(res.data);
    };

    return <button onClick={handleExport}>Export</button>;
}

API Hooks ​

Apollo-style hooks for API interactions. Like createFetchContext, these return { data, loading, failure } objects — familiar to anyone who's used Apollo Client or TanStack Query. Auto-refetch on reactive config changes, polling, and ObserverEngine-driven cache invalidation built in.

typescript
import { FetchEngine } from '@logosdx/fetch';
import { ObserverEngine } from '@logosdx/observer';
import { createApiHooks } from '@logosdx/react/api';

interface LoanEvents {
    'loan.created': { id: string; amount: number };
    'loan.deleted': { id: string };
    'audit.log': { action: string; entity: unknown };
}

const api = new FetchEngine({ baseUrl: 'https://rainbow-loans.com/api' });
const events = new ObserverEngine<LoanEvents>();

// Pre-bind engine + observer — no need to pass them in components
const {
    useQuery,
    useMutation,
    useAsync,
    createQuery,
    createMutation,
} = createApiHooks(api, events);

useQuery — Auto-Fetch with Reactive Config ​

Fires on mount and re-fetches when reactive options change. data is the parsed response body — not the full FetchResponse.

typescript
function LoanList({ page }: { page: number }) {

    const { data, loading, failure, refetch, cancel } = useQuery<Loan[]>('/loans', {
        defaults: { headers: { 'X-Api-Version': '2' } },     // Fixed — won't trigger re-fetch
        reactive: { params: { page, limit: 20 } },            // Watched — changes trigger re-fetch
        skip: !isAuthenticated,                                // Conditional execution
        pollInterval: 30_000,                                  // Re-fetch every 30s
        invalidateOn: ['loan.created', 'loan.deleted'],        // Re-fetch on observer events
    });

    if (loading) return <Spinner />;
    if (failure?.kind === 'http') return <Error status={failure.response.status} />;
    if (failure?.kind === 'transport') return <Error message={failure.error.message} />;

    return (
        <ul>
            {data?.map(loan => <li key={loan.id}>{loan.borrower}</li>)}
        </ul>
    );
}

useMutation — Fire on Demand ​

Stays idle until mutate() is called. mutate() never rejects — it resolves Promise<T | undefined>, so await it directly and check the result.

typescript
function CreateLoan() {

    const { mutate, loading, failure, data, called, reset, cancel } =
        useMutation<Loan>('post', '/loans', {
            defaults: { headers: { 'Content-Type': 'application/json' } },
            emitOnSuccess: 'loan.created',   // Emit event on observer after success
        });

    const handleSubmit = async (form: LoanForm) => {

        const loan = await mutate(form);
        if (loan) navigate(`/loans/${loan.id}`);
    };

    return (
        <form onSubmit={handleSubmit}>
            <button disabled={loading}>{loading ? 'Creating...' : 'Create Loan'}</button>
            {failure?.kind === 'http' && <p>Failed: {failure.response.status}</p>}
            {failure?.kind === 'transport' && <p>Failed: {failure.error.message}</p>}
        </form>
    );
}

emitOnSuccess supports three forms — use whichever fits your case:

typescript
// String — emit with response data as payload
emitOnSuccess: 'loan.created'

// Object — transform the payload
emitOnSuccess: { event: 'audit.log', payload: (data) => ({ action: 'create', entity: data }) }

// Array — emit multiple events
emitOnSuccess: [
    'loan.created',
    { event: 'audit.log', payload: (data) => ({ action: 'create', entity: data }) },
]

useAsync — Wrap Any Async Function ​

For when you need more than simple GET/POST — wrap any async function with loading/failure/data state. If the function returns a FetchResponse (from FetchEngine methods), data is automatically unwrapped, and an ok: false response sets failure: { kind: 'http', response } instead of being treated as success.

useAsync wraps an arbitrary function, so a rejection can't be promised as a FetchError the way useQuery/useMutation can — it sets failure: { kind: 'rejected', error } with the thrown value as-is (kind: 'rejected', not 'transport', since there's no guarantee the wrapped function even made an HTTP call).

typescript
class LoanApi extends FetchEngine {

    getLoans(page: number) {
        return this.get<Loan[]>('/loans', { params: { page } });
    }
}

function LoanDashboard({ page }: { page: number }) {

    const { data, loading, failure, refetch } = useAsync<Loan[]>(
        () => loanApi.getLoans(page),
        [page],                                                // React deps — re-executes on change
        { invalidateOn: ['loan.created'] },                    // Observer-driven invalidation
    );

    if (loading) return <Spinner />;
    if (failure?.kind === 'http') return <Error status={failure.response.status} />;
    if (failure?.kind === 'rejected') return <Error message={String(failure.error)} />;

    return <LoanTable loans={data ?? []} onRefresh={refetch} />;
}

Factory Functions — Reusable Hooks ​

Define hooks at module level, use them in any component. Factories close over the engine and observer so components stay clean:

typescript
// hooks/loans.ts — define once
const useLoans = createQuery<Loan[]>('/loans', {
    invalidateOn: ['loan.created', 'loan.deleted'],
});

const useCreateLoan = createMutation<Loan>('post', '/loans', {
    emitOnSuccess: 'loan.created',
});

// LoanPage.tsx — use anywhere
function LoanPage({ page }: { page: number }) {

    const { data, loading } = useLoans({ reactive: { params: { page } } });
    const { mutate } = useCreateLoan();

    return (
        <>
            {loading ? <Spinner /> : <LoanTable loans={data ?? []} />}
            <button onClick={() => mutate({ amount: 5000 })}>New Loan</button>
        </>
    );
}

API Hooks vs Fetch Context ​

Both createFetchContext and createApiHooks wrap the same FetchEngine. Choose based on your needs:

createFetchContextcreateApiHooks
Return shape{ data, loading, failure, ... } objects{ data, loading, failure, ... } objects
Failure accessfailure.kind === 'http' narrows to the full FetchResponse<T>Same — failure is the same discriminated union
Requires ProviderYesNo
Observer integrationManualBuilt-in (invalidateOn, emitOnSuccess)
PollingManualBuilt-in (pollInterval)
Factory functionsNoYes (createQuery, createMutation)

Use createFetchContext when you need Provider-scoped sharing and full response access. Use createApiHooks when you want Apollo-style ergonomics with automatic cache invalidation.

Type Definitions ​

The failure union — shared by every hook and factory below:

typescript
type FetchFailure<T, RH = Record<string, string>> =
    | { kind: 'transport'; error: FetchError }
    | { kind: 'http'; response: Extract<FetchResponse<T, unknown, unknown, RH>, { ok: false }> };

kind: 'transport' means no response exists at all (abort, timeout, connection lost) — error is the FetchError, with its .isCancelled() / .isTimeout() / .isConnectionLost() helpers. kind: 'http' means the server answered with a non-2xx status — response is the resolved, ok: false FetchResponse.

Fetch Context return types (from createFetchContext):

typescript
type FetchContextQueryResult<T, RH> = {
    data: T | null;
    loading: boolean;
    failure: FetchFailure<T, RH> | null;
    refetch: () => void;
    cancel: () => void;
};

type FetchContextMutationResult<T, RH> = {
    data: T | null;
    loading: boolean;
    failure: FetchFailure<T, RH> | null;
    // Never rejects — resolves undefined on any failure; read `failure` for why
    mutate: <Payload = unknown>(payload?: Payload) => Promise<T | undefined>;
    reset: () => void;
    cancel: () => void;
    called: boolean;
};

API Hooks return types (from createApiHooks):

typescript
type QueryResult<T, RH> = {
    data: T | null;
    loading: boolean;
    failure: FetchFailure<T, RH> | null;
    refetch: () => void;
    cancel: () => void;
};

type MutationResult<T, RH> = {
    data: T | null;
    loading: boolean;
    failure: FetchFailure<T, RH> | null;
    // Never rejects — resolves undefined on any failure; read `failure` for why
    mutate: <Payload = unknown>(payload?: Payload) => Promise<T | undefined>;
    reset: () => void;
    cancel: () => void;
    called: boolean;
};

// `useAsync` wraps an arbitrary function, so it can't promise a FetchError
// on rejection — `kind: 'rejected'` names only what's true for any wrapped
// promise: it rejected. `kind: 'http'` still narrows precisely when the
// resolved value structurally looks like a non-2xx response.
type AsyncFailure =
    | { kind: 'rejected'; error: unknown }
    | { kind: 'http'; response: { ok: boolean; status: number; data: unknown; request: unknown } };

type AsyncResult<T> = {
    data: T | null;
    loading: boolean;
    failure: AsyncFailure | null;
    refetch: () => void;
    cancel: () => void;
};

type QueryOptions<H, P, E> = {
    defaults?: CallConfig<H, P>;
    reactive?: CallConfig<H, P>;
    skip?: boolean;
    pollInterval?: number;
    invalidateOn?: (keyof E)[];
};

type MutationOptions<H, P, E> = {
    defaults?: CallConfig<H, P>;
    emitOnSuccess?: EmitConfig<E>;
};

Storage ​

createStorageContext wraps a StorageAdapter. Any mutation (set, remove, assign, clear) triggers a re-render automatically via internal event subscriptions.

typescript
import { createStorageContext } from '@logosdx/react/storage';
import { StorageAdapter, WebStorageDriver } from '@logosdx/storage';

interface AppStore {
    theme: 'light' | 'dark';
    userId: string;
    preferences: { lang: string; notifications: boolean };
}

const storage = new StorageAdapter<AppStore>({
    driver: new WebStorageDriver(localStorage),
    prefix: 'myapp',
});
export const [AppStorage, useAppStorage] = createStorageContext(storage);

Usage ​

typescript
function ThemeSwitcher() {

    const { get, set } = useAppStorage();

    const theme = get('theme');

    return (
        <button onClick={() => set('theme', theme === 'dark' ? 'light' : 'dark')}>
            Current: {theme}
        </button>
    );
}

Full API ​

typescript
const {
    get,       // get('theme') or get() for all values
    set,       // set('theme', 'dark') or set({ theme: 'dark', userId: '42' })
    remove,    // remove('userId')
    assign,    // assign('preferences', { lang: 'es' }) — Object.assign on value
    has,       // has('theme') → true
    clear,     // clear() — removes all prefixed keys
    scope,     // scope('theme') → scoped adapter for a single key
    keys,      // keys() → ['theme', 'userId', ...]
    instance,  // raw StorageAdapter
} = useAppStorage();

Localize ​

createLocalizeContext wraps a LocaleManager. Locale changes trigger a re-render automatically.

typescript
import { createLocalizeContext } from '@logosdx/react';
import { LocaleManager } from '@logosdx/localize';

const i18n = new LocaleManager({
    current: 'en',
    fallback: 'en',
    locales: {
        en: { code: 'en', text: 'English', labels: {
            home: { greeting: 'Hello, {name}!' },
            nav: { logout: 'Log out' },
        }},
        es: { code: 'es', text: 'Español', labels: {
            home: { greeting: '¡Hola, {name}!' },
            nav: { logout: 'Cerrar sesión' },
        }},
    },
});

export const [AppLocale, useAppLocale] = createLocalizeContext(i18n);

Usage ​

typescript
function Greeting() {

    const { t, locale, changeTo, locales } = useAppLocale();

    return (
        <div>
            <h1>{t('home.greeting', { name: 'World' })}</h1>
            <p>Current: {locale}</p>

            {locales.map(({ code, text }) => (
                <button key={code} onClick={() => changeTo(code)}>
                    {text}
                </button>
            ))}
        </div>
    );
}

API ​

PropertyTypeDescription
t(key, values?)(key: PathLeaves<Locale>, values?) => stringTranslate a key with optional interpolation
localeCodeCurrent locale code
changeTo(code)(code: Code) => voidSwitch locale — triggers re-render
locales{ code, text }[]All available locales
instanceLocaleManagerRaw manager access

State Machine ​

Two options: createStateMachineContext for context-based sharing, or useStateMachine as a standalone hook when you don't need a provider.

Context + Hook Tuple ​

typescript
import { createStateMachineContext } from '@logosdx/react/state-machine';
import { StateMachine } from '@logosdx/state-machine';

interface GameContext {
    score: number;
    level: number;
}

interface GameEvents {
    SCORE: number;
    LEVEL_UP: void;
    RESET: void;
}

type GameStates = 'idle' | 'playing' | 'paused';

const machine = new StateMachine<GameContext, GameEvents, GameStates>({
    initial: 'idle',
    context: { score: 0, level: 1 },
    transitions: { /* ... */ },
});

export const [GameProvider, useGame] = createStateMachineContext(machine);

Usage ​

typescript
function ScoreBoard() {

    const { state, context, send } = useGame();

    return (
        <div>
            <p>State: {state}</p>
            <p>Score: {context.score}</p>
            <button onClick={() => send('SCORE', 10)}>+10 Points</button>
        </div>
    );
}

Standalone Hook ​

Use useStateMachine directly when the machine doesn't need to be shared via context:

typescript
import { useStateMachine } from '@logosdx/react/state-machine';

function Counter() {

    const { state, context, send } = useStateMachine(counterMachine);

    return <button onClick={() => send('INCREMENT')}>{context.count}</button>;
}

Selector Support ​

Pass a selector to narrow the context and prevent unnecessary re-renders when unrelated context values change:

typescript
// Only re-renders when `score` changes — ignores `level` changes
const { context: score } = useGame((ctx) => ctx.score);

// Full context — re-renders on any context change
const { context } = useGame();

The selector uses deep equality comparison via equals from @logosdx/utils. If the selected value hasn't changed, the component skips the re-render.

API ​

PropertyTypeDescription
stateStatesCurrent state name (reactive)
contextSelectedFull context or selector result (reactive)
send(event, data?) => voidDispatch a transition event
instanceStateMachineRaw machine access

Compose Providers ​

composeProviders eliminates deeply nested provider trees. Pass providers in order — the first becomes the outermost wrapper.

typescript
import { composeProviders } from '@logosdx/react';

const Providers = composeProviders(
    AppObserver,
    ApiFetch,
    AppStorage,
    AppLocale,
);

// Equivalent to:
// <AppObserver>
//     <ApiFetch>
//         <AppStorage>
//             <AppLocale>
//                 {children}
//             </AppLocale>
//         </AppStorage>
//     </ApiFetch>
// </AppObserver>

Providers That Need Props ​

Some providers need configuration beyond just children. Pass them as [Provider, props] tuples:

typescript
const ThemeProvider = ({ theme, children }: { theme: string; children?: ReactNode }) =>
    createElement(ThemeContext.Provider, { value: theme }, children);

const Providers = composeProviders(
    AppObserver,
    [ThemeProvider, { theme: 'dark' }],
    ApiFetch,
    AppStorage,
);

Props are spread onto the provider alongside children. This keeps the composition flat no matter how many providers need configuration.

Empty Composition ​

Calling composeProviders() with no arguments returns a pass-through component that renders its children as-is. Useful when the provider list is built dynamically.

Rules ​

All hook methods (on, once, oncePromise, emitFactory, get, post, put, del, patch) call React hooks internally. They follow the same rules as hooks:

  • Call them at the top level of your component
  • Never call them conditionally or in loops
  • The Provider must be an ancestor in the component tree