React-River API Integration

name: react-river-api description: 'Generate @zerologix/react-river provider and component wiring code. Use when: building river providers, wiring API calls into react-river, creating asyncNotifierProvider, promiseProvider, stateProvider, notifierProvider, adding river state management, connecting services to river providers, creating river data layer for a feature.' argument-hint: 'Describe the API/feature to integrate (e.g. "fetch user profile with reload action")'

React-River API Integration

When to Use

  • Building a new data layer for a feature using @zerologix/react-river
  • Connecting an existing service method to a river provider
  • Adding river-based state (local UI state, async fetch, complex mutations)
  • Wiring a provider into a React component

Step 1 — Choose the Provider Type

Answer these questions to pick the right primitive:

Situation Provider
Inject a service instance (no async) stateProvider
One-time fetch, read-only promiseProvider
One-time fetch with a parameter promiseProviderFamily
Fetch + mutations / reload / optimistic update asyncNotifierProvider
Fetch + mutations + parameter asyncNotifierProviderFamily
Derived / computed from other providers provider
Complex sync state with methods notifierProvider

Default heuristic for fetch-based features:

  • Read-only fetch → promiseProvider (add Family if parameterised)
  • Fetch + mutations / reload → asyncNotifierProvider (add Family if parameterised)

For the remaining cases use the table directly:

  • Service injection (no async) → stateProvider
  • Derived / computed value → provider
  • Complex sync state with methods (no async fetch) → notifierProvider

If the feature matches multiple rows (e.g. needs a parameter and derived computation), ask the user a clarifying question before generating code rather than guessing.

Step 2 — Provider File Structure

Determine the target directory by inspection:

  1. Check whether src/river/ already exists in the package — if yes, use it.
  2. Otherwise check for src/providers/ — if yes, use it.
  3. If neither exists, default to src/river/ and confirm the path with the user before creating the file.

stateProvider (service injection)

import { stateProvider } from '@zerologix/react-river';
import type { MyService } from '@/services/MyService';

export const myServiceProvider = stateProvider<MyService | null>(() => null, {
  name: 'MyServiceProvider',
});

promiseProvider (simple fetch)

import { promiseProvider } from '@zerologix/react-river';
import { myServiceProvider } from './myService';

export const myDataProvider = promiseProvider(
  async (ref) => {
    const service = ref.watch(myServiceProvider);
    if (!service) return null;
    return await service.fetchData();
  },
  { name: 'MyDataProvider' },
);

promiseProviderFamily (parameterised fetch)

import { promiseProviderFamily } from '@zerologix/react-river';
import { myServiceProvider } from './myService';

export const myDataByIdProvider = promiseProviderFamily(
  async (ref, id: string) => {
    const service = ref.watch(myServiceProvider);
    if (!service) return null;
    return await service.fetchDataById(id);
  },
  { name: 'MyDataByIdProvider' },
);

asyncNotifierProvider (fetch + mutations)

import {
  AsyncNotifier,
  asyncNotifierProvider,
  asyncData,
  asyncLoading,
} from '@zerologix/react-river';
import type { MyItem } from '@/services/MyService';
import { myServiceProvider } from './myService';

class MyNotifier extends AsyncNotifier<MyItem[] | null> {
  async build() {
    const service = this.ref.watch(myServiceProvider);
    if (!service) return null;
    return await service.fetchItems();
  }

  async reload() {
    this.state = asyncLoading(this.state.data);
    const service = this.ref.read(myServiceProvider);
    if (!service) return;
    const items = await service.fetchItems();
    this.state = asyncData(items);
  }

  async addItem(item: MyItem) {
    const current = this.state.data ?? [];
    // Optimistic update: apply locally first, then persist
    this.state = asyncData([...current, item]);
    try {
      await this.ref.read(myServiceProvider)?.addItem(item);
    } catch (e) {
      // Roll back to previous state on failure
      this.state = asyncData(current);
      throw e;
    }
  }
}

export const myItemsProvider = asyncNotifierProvider(() => new MyNotifier(), {
  name: 'MyItemsProvider',
});

provider (derived / computed)

import { provider } from '@zerologix/react-river';
import { myServiceProvider } from './myService';
import { myDataProvider } from './myData';

export const myDerivedProvider = provider(
  (ref) => {
    const raw = ref.watch(myDataProvider);
    // transform / derive — re-runs whenever myDataProvider changes
    return raw?.items.filter((item) => item.active) ?? [];
  },
  { name: 'MyDerivedProvider' },
);

notifierProvider (complex sync state)

import { Notifier, notifierProvider } from '@zerologix/react-river';

class MyStateNotifier extends Notifier<MyState> {
  build(): MyState {
    return { count: 0, filter: 'all' };
  }

  increment() {
    this.state = { ...this.state, count: this.state.count + 1 };
  }

  setFilter(filter: string) {
    this.state = { ...this.state, filter };
  }
}

export const myStateProvider = notifierProvider(() => new MyStateNotifier(), {
  name: 'MyStateProvider',
});

asyncNotifierProviderFamily (fetch + mutations + parameter)

import {
  AsyncNotifier,
  asyncNotifierProviderFamily,
  asyncData,
  asyncLoading,
} from '@zerologix/react-river';
import { myServiceProvider } from './myService';

class MyParamNotifier extends AsyncNotifier<MyItem[] | null> {
  constructor(private variables: MyQueryParams) {
    super();
  }

  async build() {
    const service = this.ref.watch(myServiceProvider);
    if (!service) return null;
    return await service.fetchItems(this.variables);
  }

  async keepDataAndRefetch() {
    this.state = asyncLoading(this.state.data);
    const service = this.ref.read(myServiceProvider);
    if (!service) return;
    const items = await service.fetchItems(this.variables);
    this.state = asyncData(items);
  }
}

export const myItemsByParamProvider = asyncNotifierProviderFamily(
  (_ref, variables: MyQueryParams) => new MyParamNotifier(variables),
  { name: 'MyItemsByParamProvider' },
);

Step 3 — Export from index

Add the new provider to the package's src/river/index.ts (or the nearest barrel):

export * from './myData';

Step 4 — Wire Into a Component

Read-only display (promiseProvider / asyncNotifierProvider)

import { useRiverWatch } from '@zerologix/react-river';
import { myDataProvider } from '@/river/myData';

function MyComponent() {
  const data = useRiverWatch(myDataProvider);

  if (data.isLoading) return <div>Loading...</div>;
  if (data.isError) return <div>Error</div>;

  return <div>{data.data?.name}</div>;
}

With mutation actions

import { useRiverRef, useRiverWatch } from '@zerologix/react-river';
import { myItemsProvider } from '@/river/myItems';

function MyList() {
  const items = useRiverWatch(myItemsProvider);
  const riverRef = useRiverRef();

  return (
    <div>
      {items.data?.map((item) => (
        <div key={item.id}>{item.name}</div>
      ))}
      <button onClick={() => riverRef.read(myItemsProvider.notifier).reload()}>
        Reload
      </button>
    </div>
  );
}

With selector (avoid unnecessary re-renders)

const symbolIds = useRiverWatch(
  myItemsProvider,
  ({ data }) => data?.map((item) => item.id) ?? [],
);

Parameterised family

const data = useRiverWatch(myDataByIdProvider('abc123'));
// or with selector:
const name = useRiverWatch(myDataByIdProvider('abc123'), (v) => v.data?.name);

Step 5 — Naming Conventions

Entity Convention Example
Provider variable camelCase + Provider suffix tradingAccountsProvider
Notifier class PascalCase + Notifier AccountAdditionalInfoNotifier
name option PascalCase + Provider 'TradingAccountsProvider'
Provider file camelCase, in src/river/ tradingAccount.ts

Quick Decision Checklist

Before generating code, confirm:

  • [ ] What data does the provider expose? (type / shape)
  • [ ] Which service / API call does it wrap?
  • [ ] Does it depend on other providers? (use ref.watch / ref.read)
  • [ ] Are there mutations or a reload action? → use asyncNotifierProvider
  • [ ] Does it need a parameter? → use *Family variant
  • [ ] Which component(s) will consume it?

Reference: AsyncValue Shape

useRiverWatch on an async provider returns:

{
  status: 'loading' | 'data' | 'error';
  data?: T;
  error?: unknown;
  isLoading: boolean;
  isError: boolean;
}

Always guard isLoading / isError before accessing data.

Error Handling in Notifiers

When a service call inside build(), reload(), or a mutation can throw, wrap it with try/catch and set asyncError so the AsyncValue reflects the failure:

import { asyncData, asyncError, asyncLoading } from '@zerologix/react-river';

async reload() {
  this.state = asyncLoading(this.state.data);
  try {
    const service = this.ref.read(myServiceProvider);
    if (!service) return;
    const items = await service.fetchItems();
    this.state = asyncData(items);
  } catch (error) {
    this.state = asyncError(error, this.state.data); // preserve stale data
  }
}

The asyncError(error, staleData) call lets the UI show an error state while optionally retaining the previous data for display.