npm package: @radio-garden/rematch
Description: A lightweight, type-safe state management library built on Redux. Inspired by [Rematch](https://rematchjs.org/), with a simplified API and enhanced TypeScript support.
Latest version: 1.1.0

# @radio-garden/rematch

A lightweight, type-safe state management library built on Redux. Inspired by [Rematch](https://rematchjs.org/), with a simplified API and enhanced TypeScript support.

## Differences from Rematch

- **No plugins** - Selectors and other features are built-in, not separate packages
- **`store.selector` and `store.select`** - Two ways to access selectors: with state argument (for `useSelector`) or bound to current state
- **Auto-generated property selectors** - `selector.model.propertyName` is automatically created for each state property
- **`store.watchSelector`** - Built-in reactive state watching, also available inside effects
- **`store.createSelector`** - Built-in memoized selector creation using reselect
- **Selectors defined on models** - Define selectors directly in the model, with access to `createSelector` and cross-model selectors
- **Effects receive more utilities** - Effects factory receives `{ dispatch, getState, select, selector, watchSelector }`
- **`onStore` hook** - Set up reactive side effects when the store is ready

## Installation

```bash
npm install @radio-garden/rematch redux reselect
# or
pnpm add @radio-garden/rematch redux reselect
```

## Quick Start

```typescript
import { createModel, createRematchStore, type Models } from '@radio-garden/rematch';

// Define your models interface
interface AppModels extends Models<AppModels> {
  counter: typeof counter;
}

// Create a model
const counter = createModel<{ count: number }, AppModels>()({
  state: { count: 0 },
  reducers: {
    increment(state) {
      return { count: state.count + 1 };
    },
    add(state, payload: number) {
      return { count: state.count + payload };
    }
  },
  effects: ({ dispatch }) => ({
    async incrementAsync() {
      await new Promise(resolve => setTimeout(resolve, 1000));
      dispatch.counter.increment();
    }
  }),
  selectors: ({ selector }) => ({
    doubled: state => selector.counter.count(state) * 2
  })
});

// Create the store
const store = createRematchStore<AppModels>({
  name: 'MyApp',
  models: { counter }
});

// Use it
store.dispatch.counter.increment();
store.dispatch.counter.add(5);
await store.dispatch.counter.incrementAsync();

console.log(store.getState().counter.count); // 7
console.log(store.select.counter.doubled()); // 14
```

## API

### `createRematchStore<TModels>(config)`

Creates a new store instance.

```typescript
const store = createRematchStore<AppModels>({
  name: 'MyApp',                    // Optional store name
  models: { counter, user },        // Your models
  redux: {                          // Optional Redux configuration
    middlewares: [],                // Custom Redux middlewares
    devtoolOptions: { disabled: false },
    devtoolCompose: composeWithDevTools  // For React Native
  }
});
```

### `createModel<TState, TModels>()(modelConfig)`

Creates a typed model definition.

```typescript
const user = createModel<{ name: string; age: number }, AppModels>()({
  state: { name: '', age: 0 },
  reducers: {
    setName(state, name: string) {
      return { ...state, name };
    },
    setAge(state, age: number) {
      return { ...state, age };
    }
  },
  effects: store => ({
    async fetchUser(userId: string) {
      const user = await api.getUser(userId);
      this.setName(user.name);
      this.setAge(user.age);
    }
  }),
  selectors: ({ selector, createSelector }) => ({
    // Simple selector
    isAdult: state => selector.user.age(state) >= 18,

    // Memoized selector using createSelector
    fullInfo: createSelector(create =>
      create(
        [selector.user.name, selector.user.age],
        (name, age) => `${name} (${age} years old)`
      )
    )
  })
});
```

Selectors can be defined as a plain object or a factory function:

```typescript
// Plain object (when you don't need cross-model selectors)
selectors: {
  doubled(state): number {
    return state.counter.count * 2;
  }
}

// Factory function (when you need access to other selectors or createSelector)
selectors: ({ selector, createSelector }) => ({
  doubled(state): number {
    return selector.counter.count(state) * 2;
  }
})
```

The factory function receives:
- `selector` - Access selectors from any model
- `createSelector` - Create memoized selectors using reselect
- `getState` - Get current root state (useful for deriving types)

**Important:** Always add explicit return type annotations to selectors to avoid recursive type definitions:

```typescript
selectors: ({ selector }) => ({
  // Explicit return types prevent TypeScript circular reference errors
  tabState(state): TabState | undefined {
    const { tab, tabs } = state.browser;
    return tab ? tabs[tab] : undefined;
  },
  currentPage(state): Page | undefined {
    return selector.browser.tabState(state)?.currentPage;
  }
})
```

For complex memoized selectors, use a type helper to ensure proper typing:

```typescript
// Define a type helper for selectors
type CreateSelector<TRootState, TReturn> = (state: TRootState) => TReturn;

const browser = createModel<BrowserState, AppModels>()({
  state: { /* ... */ },
  selectors: ({ selector, createSelector, getState }) => {
    // Derive RootState type from getState
    type RootState = ReturnType<typeof getState>;

    // Use the type helper for complex return types
    const pages: CreateSelector<RootState, {
      currentPage: Page;
      previousPage: Page | undefined;
    }> = createSelector(create =>
      create(
        [selector.browser.history, selector.browser.pageCache],
        (history, pageCache) => ({
          currentPage: pageCache[history.current],
          previousPage: history.previous ? pageCache[history.previous] : undefined
        })
      )
    );

    return { pages };
  }
});
```

### Store Methods

#### `store.dispatch`

Dispatch reducers and effects:

```typescript
store.dispatch.counter.increment();           // No payload
store.dispatch.counter.add(5);                // With payload
store.dispatch.counter.addWithMeta(5, { multiplier: 2 }); // With meta
await store.dispatch.user.fetchUser('123');   // Async effect
```

#### `store.selector`

Get selectors that require state as an argument. Useful for React hooks like `useSelector` or when you need to work with a specific state snapshot.

```typescript
const state = store.getState();

// Get entire model state
store.selector.counter(state);        // { count: 5 }
store.selector.user(state);           // { name: 'John', age: 25 }

// Auto-generated property selectors
store.selector.counter.count(state);  // 5
store.selector.user.name(state);      // 'John'

// Custom selectors defined in the model
store.selector.counter.doubled(state); // 10
store.selector.user.isAdult(state);    // true
```

Use with React Redux:

```typescript
import { useSelector } from 'react-redux';

function Counter() {
  const count = useSelector(store.selector.counter.count);
  const doubled = useSelector(store.selector.counter.doubled);
  return <div>{count} (doubled: {doubled})</div>;
}
```

#### `store.select`

Get bound selectors that automatically use current state. Unlike `store.selector`, these don't require passing state as an argument.

```typescript
// Get entire model state
store.select.counter();        // { count: 5 }
store.select.user();           // { name: 'John', age: 25 }

// Auto-generated property selectors
store.select.counter.count();  // 5
store.select.user.name();      // 'John'
store.select.user.age();       // 25

// Custom selectors defined in the model
store.select.counter.doubled(); // 10
store.select.user.isAdult();    // true
```

This is useful when you need quick access to the current state without managing state references:

```typescript
// Inside an effect or anywhere with store access
if (store.select.user.isAdult()) {
  console.log(`User ${store.select.user.name()} is an adult`);
}
```

#### `store.createSelector`

Create memoized selectors using reselect:

```typescript
const selectTotal = store.createSelector(create =>
  create(
    [state => state.counter.count, state => state.user.age],
    (count, age) => count + age
  )
);

selectTotal(store.getState()); // Memoized result
```

#### `store.watchSelector`

Watch for state changes:

```typescript
// Watch a single selector
const unsubscribe = store.watchSelector(
  state => state.counter.count,
  (newValue, oldValue) => {
    console.log(`Count changed from ${oldValue} to ${newValue}`);
  }
);

// Watch multiple selectors
store.watchSelector(
  [state => state.counter.count, state => state.user.age],
  ([newCount, newAge], [oldCount, oldAge]) => {
    console.log('Values changed');
  }
);

// Trigger immediately with current value
store.watchSelector(
  state => state.counter.count,
  (value) => console.log('Current count:', value),
  { initial: true }
);

// Stop watching
unsubscribe();
```

#### `store.subscribe`

Low-level Redux subscription:

```typescript
const unsubscribe = store.subscribe(() => {
  console.log('State changed:', store.getState());
});
```

#### `store.getState`

Get the current state:

```typescript
const state = store.getState();
// { counter: { count: 0 }, user: { name: '', age: 0 } }
```

## React Native Integration

For React Native with Flipper or Reactotron, pass a custom `devtoolCompose`:

```typescript
import { composeWithDevTools } from '@redux-devtools/extension';

const store = createRematchStore<AppModels>({
  models: { counter },
  redux: {
    devtoolCompose: composeWithDevTools
  }
});
```

To disable devtools:

```typescript
const store = createRematchStore<AppModels>({
  models: { counter },
  redux: {
    devtoolOptions: { disabled: true }
  }
});
```

## Effects

The effects factory receives the store object with these properties:
- `dispatch` - Dispatch actions to any model
- `getState` - Get current root state
- `select` - Bound selectors (no state argument needed)
- `selector` - Selectors requiring state argument
- `watchSelector` - Watch for state changes

You can use the store directly or destructure what you need:

```typescript
// Using store directly
effects: store => ({
  async syncAll() {
    await this.fetchData();
    store.dispatch.otherModel.refresh();
  }
})

// Destructuring
effects: ({ dispatch, select, watchSelector, selector }) => ({
  async initialize() {
    // Use select for quick state access
    if (select.user.isAdult()) {
      dispatch.features.enableAdultContent();
    }

    // Watch for state changes within effects
    watchSelector(
      [selector.user.name, selector.settings.theme],
      ([name, theme]) => {
        console.log('User or theme changed');
      },
      { initial: true }
    );
  }
})
```

### Effect Parameters

Each effect receives the payload as first argument and `rootState` as second:

```typescript
effects: ({ dispatch }) => ({
  async updateIfNeeded(threshold: number, rootState) {
    if (rootState.counter.count > threshold) {
      dispatch.counter.reset();
    }
  }
})
```

### Effects with `this` Context

Inside effects, `this` is bound to the model's dispatch object:

```typescript
effects: {
  async saveUser(userData: UserData) {
    const saved = await api.saveUser(userData);
    this.setName(saved.name);  // Calls user.setName reducer
    this.setAge(saved.age);    // Calls user.setAge reducer
  }
}
```

## onStore Hook

The `onStore` hook is called after the store is fully created. Use it to set up reactive side effects that respond to state changes.

This approach avoids circular dependency issues that would occur if you tried to import the store directly into your model file - the store is passed to you instead.

```typescript
const counter = createModel<{ count: number }, AppModels>()({
  state: { count: 0 },
  reducers: {
    increment: state => ({ count: state.count + 1 }),
  },
  onStore: ({ watchSelector, selector }) => {
    // React to count changes
    watchSelector(selector.counter.count, (newCount, oldCount) => {
      console.log(`Count changed: ${oldCount} → ${newCount}`);
      syncToServer(newCount);
    });
  }
});
```

The `onStore` callback receives the full store object, which you can destructure to get only what you need:

```typescript
// Full store access
onStore: store => {
  store.watchSelector(/* ... */);
}

// Destructured
onStore: ({ watchSelector, selector, dispatch, select, getState }) => {
  // Set up watchers, dispatch initial actions, etc.
}
```

### Common Use Cases

**Syncing state to external services:**

```typescript
onStore: ({ watchSelector, selector }) => {
  watchSelector(selector.user.preferences, prefs => {
    localStorage.setItem('preferences', JSON.stringify(prefs));
  });
}
```

**Analytics tracking:**

```typescript
onStore: ({ watchSelector, selector }) => {
  watchSelector(
    [selector.player.currentTrack, selector.player.isPlaying],
    ([track, isPlaying]) => {
      if (track && isPlaying) {
        analytics.trackPlay(track.id);
      }
    }
  );
}
```

## React Integration

Wrap your app with the Redux Provider and use `useSelector` with `store.selector`:

```typescript
import { Provider, useSelector } from 'react-redux';
import { store } from './store';

// App wrapper
function App() {
  return (
    <Provider store={store}>
      <Counter />
    </Provider>
  );
}

// Component using selectors
function Counter() {
  const count = useSelector(store.selector.counter.count);
  const doubled = useSelector(store.selector.counter.doubled);
  const user = useSelector(store.selector.user);

  return (
    <div>
      <p>Count: {count} (doubled: {doubled})</p>
      <p>User: {user.name}</p>
      <button onClick={() => store.dispatch.counter.increment()}>
        Increment
      </button>
      <button onClick={() => store.dispatch.counter.incrementAsync()}>
        Increment Async
      </button>
    </div>
  );
}
```

## TypeScript

The library is fully typed. Define your models interface for complete type inference:

```typescript
interface AppModels extends Models<AppModels> {
  counter: typeof counter;
  user: typeof user;
}

// All dispatch, selector, and select calls are fully typed
store.dispatch.counter.add(5);        // payload must be number
store.select.user.name();             // returns string
store.selector.counter.doubled(state); // returns number
```

## License

MIT