npm package: @plasius/react-state
Description: Tiny, testable, typesafe React Scoped Store helper.
Author: Plasius LTD
Latest version: 1.2.8

# @plasius/react-state

[![npm version](https://img.shields.io/npm/v/@plasius/react-state.svg)](https://www.npmjs.com/package/@plasius/react-state)
[![Build Status](https://img.shields.io/github/actions/workflow/status/Plasius-LTD/react-state/ci.yml?branch=main&label=build&style=flat)](https://github.com/Plasius-LTD/react-state/actions/workflows/ci.yml)
[![coverage](https://img.shields.io/codecov/c/github/Plasius-LTD/react-state)](https://codecov.io/gh/Plasius-LTD/react-state)
[![License](https://img.shields.io/github/license/Plasius-LTD/react-state)](./LICENSE)
[![Code of Conduct](https://img.shields.io/badge/code%20of%20conduct-yes-blue.svg)](./CODE_OF_CONDUCT.md)
[![Security Policy](https://img.shields.io/badge/security%20policy-yes-orange.svg)](./SECURITY.md)
[![Changelog](https://img.shields.io/badge/changelog-md-blue.svg)](./CHANGELOG.md)

Apache-2.0. ESM + CJS builds. TypeScript types included.

---

## Overview

`@plasius/react-state` provides a scoped state management solution for React applications. It allows developers to create isolated, testable, and composable stores without introducing heavy dependencies.

**Key traits**
- React 18/19 compatible; uses `useSyncExternalStore` for tearing-safe snapshots.
- Distinct-until-changed dispatch flow to avoid needless notifications and re-renders.
- Selector subscriptions accept custom equality to prevent redundant renders when slices are unchanged.
- Scoped Provider recreates its store when `initialState` changes so fresh trees start from fresh state.

---

## Installation

```bash
npm install @plasius/react-state
```

---

## Demo

```bash
npm run build
node demo/example.mjs
```

See `demo/README.md` for the local sanity-check scaffold.

---

## Usage Example

### Accessing the store

```tsx
import { createStore } from '@plasius/react-state'

type Action =
  | { type: "INCREMENT_VALUE"; payload: number }
  | { type: "SET_VALUE"; payload: number }
  | { type: "DECREMENT_VALUE"; payload: number };

interface State {
  value: number;
}

const initialState: State = { value: 0 };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "INCREMENT_VALUE":
      return { ...state, value: state.value + action.payload };
    case "DECREMENT_VALUE":
      return { ...state, value: state.value - action.payload };
    case "SET_VALUE":
      // Distinct-until-changed: return the SAME reference if no change,
      // so listeners relying on referential equality will not fire.
      if (action.payload === state.value) return state;
      return { ...state, value: action.payload };
    default:
      return state;
  }
}

const store = createStore<State, Action>(reducer, initialState);

function doSomething() {
  store.dispatch({ type: "INCREMENT_VALUE", payload: 1});
}
```

### Scoped react hooks

```tsx
import { createScopedStore } from '@plasius/react-state'

type Action =
  | { type: "INCREMENT_VALUE"; payload: number }
  | { type: "SET_VALUE"; payload: number }
  | { type: "DECREMENT_VALUE"; payload: number };

interface State {
  value: number;
}

const initialState: State = { value: 0 };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "INCREMENT_VALUE":
      return { ...state, value: state.value + action.payload };
    case "DECREMENT_VALUE":
      return { ...state, value: state.value - action.payload };
    case "SET_VALUE":
      // Distinct-until-changed: return the SAME reference if no change,
      // so listeners relying on referential equality will not fire.
      if (action.payload === state.value) return state;
      return { ...state, value: action.payload };
    default:
      return state;
  }
}

const store = createStore<State, Action>(reducer, initialState);

const Counter = () => {
    const state = store.useStore();
    const dispatch = store.useDispatch();

    return (
      <div>
        <button id="counter-inc" onClick={() => dispatch({ type: "inc" })}>
          +
        </button>
        <button id="counter-dec" onClick={() => dispatch({ type: "dec" })}>
          -
        </button>
        <input
          aria-label="Counter value"
          title=""
          id="counter-set"
          type="number"
          value={state.count}
          onChange={(e) =>
            dispatch({ type: "set", payload: Number(e.target.value) })
          }
        />
      </div>
    );
  };

function App() { 
  return (<><store.Provider><Counter /></store.Provider></>);
}
```

---

## Contributing

We welcome contributions! Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.

- [Code of Conduct](./CODE_OF_CONDUCT.md)
- [Contributor License Agreement](./legal/CLA.md)

---

## License

This project is licensed under the terms of the [Apache 2.0 license](./LICENSE).