Skip to content

Incremental Complexity

useState without regrets

Clean state management should be easy, like useState.

Developers should feel completely free to use useState for simple features.

A smooth path to reducers

But when state needs to change in more complex ways, there are 2 approaches:

Event handlers—scattered state logic ❌

Reducers—colocated state logic ✅

Reducers are great, but refactoring from useState to useReducer takes a lot of work.

StateAdapt provides a smoother path to reducers:

1. Replace useState with useAdapt

tsx
function SimpleStateAdapt() {
  const [name, setName] = useState('Bob'); 
  const [name, setName] = useAdapt('Bob'); 
  return (
    <>
      <h2>Hello {name}!</h2>
      <h2>Hello {name.state}!</h2>
      <button onClick={() => setName('Bilbo')}>Change Name</button>
    </>
  );
}

Result:

tsx
function SimpleStateAdapt() {
  const [name, setName] = useAdapt('Bob');
  return (
    <>
      <h2>Hello {name.state}!</h2>
      <button onClick={() => setName('Bilbo')}>Change Name</button>
    </>
  );
}

2. Add Reducers

tsx
function ReducedState() {
  const [name, setName] = useAdapt('Bob'); 
  const [name, setName] = useAdapt('Bob', {
    reverse: name => name.split('').reverse().join(''), // name type inferred
  }); 
  return (
    <>
      <h2>Hello {name.state}!</h2>
      <button onClick={() => setName('Bilbo')}>Change Name</button>
      <button onClick={() => setName.reverse()}>Reverse Name</button>
    </>
  );
}

Result:

tsx
function ReducedState() {
  const [name, setName] = useAdapt('Bob', {
    reverse: name => name.split('').reverse().join(''),
  });
  return (
    <>
      <h2>Hello {name.state}!</h2>
      <button onClick={() => setName('Bilbo')}>Change Name</button>
      <button onClick={() => setName.reverse()}>Reverse Name</button>
    </>
  );
}

A smooth path to shared state

Moving local state to shared state should be easy.

useAdapt easily splits into adapt and useStore:

tsx
const nameStore = adapt('Bob', {
  reverse: name => name.split('').reverse().join(''), 
}); 

function SharedState() {
  const [name, setName] = useAdapt('Bob', {
    reverse: name => name.split('').reverse().join(''), 
  }); 
  const [name, setName] = useStore(nameStore); 
  return (
    <>
      <h2>Hello {name.state}!</h2>
      <button onClick={() => setName('Bilbo')}>Change Name</button>
      <button onClick={() => setName.reverse()}>Reverse Name</button>
    </>
  );
}

Result:

tsx
const nameStore = adapt('Bob', {
  reverse: name => name.split('').reverse().join(''),
});

function SharedState() {
  const [name, setName] = useStore(nameStore);
  return (
    <>
      <h2>Hello {name.state}!</h2>
      <button onClick={() => setName('Bilbo')}>Change Name</button>
      <button onClick={() => setName.reverse()}>Reverse Name</button>
    </>
  );
}

A smooth path to shared, derived state

Nothing is as easy as derived state in React components:

tsx
const [name, setName] = useAdapt(nameStore);

const randomCaseName = name.state
  .split('') 
  .map(c => (Math.random() > 0.5 ? c : c.toUpperCase())) 
  .join(''); 

// ...

Since nothing can match this syntax, anything you do to share this logic with other components will require some refactoring.

One way StateAdapt addresses this is by allowing selectors to be defined alongside state from the start:

tsx
function SharedDerivedState() {
  const [name, setName] = useAdapt('Bob', {
    reverse: name => name.split('').reverse().join(''),
    seletors: {
      randomCase: (
        name, 
      ) =>
        name
          .split('') 
          .map(c => (Math.random() > 0.5 ? c : c.toUpperCase())) 
          .join(''), 
    }, 
  });
  return (
    <>
      <h2>Hello {name.state}!</h2>
      <h2>Hello {name.randomCase}!</h2>
      <button onClick={() => setName('Bilbo')}>Change Name</button>
      <button onClick={() => setName.reverse()}>Reverse Name</button>
    </>
  );
}

Now if you need to share it, the selectors can just move with the state:

tsx
const nameStore = adapt('Bob', {
  reverse: name => name.split('').reverse().join(''), 
  seletors: {
    randomCase: (
      name, 
    ) =>
      name
        .split('') 
        .map(c => (Math.random() > 0.5 ? c : c.toUpperCase())) 
        .join(''), 
  }, 
}); 

function SharedDerivedState() {
  const [name, setName] = useAdapt('Bob', {
    reverse: name => name.split('').reverse().join(''), 
    seletors: {
      randomCase: (
        name, 
      ) =>
        name
          .split('') 
          .map(c => (Math.random() > 0.5 ? c : c.toUpperCase())) 
          .join(''), 
    }, 
  }); 
  const [name, setName] = useStore(nameStore); 
  return (
    <>
      <h2>Hello {name.state}!</h2>
      <h2>Hello {name.randomCase}!</h2>
      <button onClick={() => setName('Bilbo')}>Change Name</button>
      <button onClick={() => setName.reverse()}>Reverse Name</button>
    </>
  );
}

Result:

tsx
const nameStore = adapt('Bob', {
  reverse: name => name.split('').reverse().join(''),
  seletors: {
    randomCase: name =>
      name
        .split('')
        .map(c => (Math.random() > 0.5 ? c : c.toUpperCase()))
        .join(''),
  },
});

function SharedDerivedState() {
  const [name, setName] = useStore(nameStore);
  return (
    <>
      <h2>Hello {name.state}!</h2>
      <h2>Hello {name.randomCase}!</h2>
      <button onClick={() => setName('Bilbo')}>Change Name</button>
      <button onClick={() => setName.reverse()}>Reverse Name</button>
    </>
  );
}

A smooth path to state logic reuse

Decoupled

State logic that references specific event sources and specific state can require major refactoring if multiple states end up needing it.

State adapters provide a smooth path to extracting logic away from specific event sources and state:

tsx
const nameStore = adapt('Bob', { 
const nameAdapter = createAdapter<string>()({ 
  reverse: name => name.split('').reverse().join(''),
  selectors: {
    randomCase: name =>
      name
        .split('')
        .map(c => (Math.random() > 0.5 ? c : c.toUpperCase()))
        .join(''),
  },
});
const name1Store = adapt('Bob', nameAdapter); 
const name2Store = adapt('Kat', nameAdapter); 

// ...

Result:

tsx
const nameAdapter = createAdapter<string>()({
  reverse: name => name.split('').reverse().join(''),
  selectors: {
    randomCase: name =>
      name
        .split('')
        .map(c => (Math.random() > 0.5 ? c : c.toUpperCase()))
        .join(''),
  },
});
const name1Store = adapt('Bob', nameAdapter);
const name2Store = adapt('Kat', nameAdapter);

function StateAdapters() {
  const [name1, setName1] = useStore(name1Store);
  const [name2, setName2] = useStore(name2Store);
  return (
    <>
      <h2>Hello {name1.state}!</h2>
      <h2>Hello {name1.randomCase}!</h2>
      <button onClick={() => setName1('Bilbo')}>Change Name</button>
      <button onClick={() => setName1.reverse()}>Reverse Name</button>

      <h2>Hello {name2.state}!</h2>
      <h2>Hello {name2.randomCase}!</h2>
      <button onClick={() => setName2('Bilbo')}>Change Name</button>
      <button onClick={() => setName2.reverse()}>Reverse Name</button>
    </>
  );
}

Composable

State adapters provide a smooth path to adapting to changes in state shape. If you start with a simple boolean adapter, for example:

ts
const booleanAdapter = createAdapter<boolean>()({
  toggle: state => !state,
});

And later decide to have multiple boolean properties of a larger state object:

ts
type State = {
  isActive: boolean;
  isVisible: boolean;
};

You can reuse the simple boolean logic by creating a joined adapter that extends it:

ts
const adapter = joinAdapters<State>()({
  isActive: booleanAdapter,
  isVisible: booleanAdapter,
})();

This creates reducers in adapter called toggleIsActive and toggleIsVisible that toggle the respective properties.

State adapters are also an opportunity to share generic state management logic. Check out the adapters you can import from @state-adapt/core/adapters.

A smooth path to reactive state

When multiple states need to change after an event, there are 2 approaches:

Event handlers updating multiple states—scattered state changes ❌

States reacting to events—colocated state changes ✅

Reactive state is great, but it takes a lot of work to refactor to a state management library that supports event-driven state.

StateAdapt provides a smooth path to reactive state:

tsx
// ...

const onResetAll = source(); // Event source

const name1Store = adapt('Bob', nameAdapter); 
const name1Store = adapt('Bob', { 
  adapter: nameAdapter, 
  sources: { reset: onResetAll }, // calls `reset` reducer (included)
}); 
const name2Store = adapt('Kat', nameAdapter); 
const name2Store = adapt('Kat', { 
  adapter: nameAdapter, 
  sources: { reset: onResetAll }, // calls `reset` reducer (included)
}); 

// ...

      <button onClick={onResetAll}>Reset All</button>
    </>
  );
}

Result:

tsx
const nameAdapter = createAdapter<string>()({
  reverse: name => name.split('').reverse().join(''),
  selectors: {
    randomCase: name =>
      name
        .split('')
        .map(c => (Math.random() > 0.5 ? c : c.toUpperCase()))
        .join(''),
  },
});

const onResetAll = source();

const name1Store = adapt('Bob', {
  adapter: nameAdapter,
  sources: { reset: onResetAll },
});
const name2Store = adapt('Kat', {
  adapter: nameAdapter,
  sources: { reset: onResetAll },
});

function ReactiveState() {
  const [name1, setName1] = useStore(name1Store);
  const [name2, setName2] = useStore(name2Store);
  return (
    <>
      <h2>Hello {name1.state}!</h2>
      <h2>Hello {name1.randomCase}!</h2>
      <button onClick={() => setName1('Bilbo')}>Change Name</button>
      <button onClick={() => setName1.reverse()}>Reverse Name</button>

      <h2>Hello {name2.state}!</h2>
      <h2>Hello {name2.randomCase}!</h2>
      <button onClick={() => setName2('Bilbo')}>Change Name</button>
      <button onClick={() => setName2.reverse()}>Reverse Name</button>

      <button onClick={onResetAll}>Reset All</button>
    </>
  );
}

A smooth path to multi-store, shared, derived states

State derived from multiple stores can glitch and over-compute in some libraries, especially if RxJS-based.

But StateAdapt's joinStores is glitch-free and efficient, preventing the need for complicated workarounds and refactors:

tsx
// ...

const name12Store = joinStores({ 
  name1: name1Store, 
  name2: name2Store, 
})({ 
  bobcat: s => s.name1 === 'Bob' && s.name2 === 'Kat'
})(); 

// ...

  const [{ bobcat }] = useStore(name12Store); 

  // ...

      {bobcat && <h2>Hello, bobcat!</h2>} 
    </>
  );
}

Result:

tsx
const nameAdapter = createAdapter<string>()({
  reverse: name => name.split('').reverse().join(''),
  selectors: {
    randomCase: name =>
      name
        .split('')
        .map(c => (Math.random() > 0.5 ? c : c.toUpperCase()))
        .join(''),
  },
});

const onResetAll = source();

const name1Store = adapt('Bob', {
  adapter: nameAdapter,
  sources: { reset: onResetAll },
});
const name2Store = adapt('Kat', {
  adapter: nameAdapter,
  sources: { reset: onResetAll },
});

const name12Store = joinStores({
  name1: name1Store,
  name2: name2Store,
})({
  bobcat: s => s.name1 === 'Bob' && s.name2 === 'Kat',
})();

function MultiStoreSharedDerivedState() {
  const [name1, setName1] = useStore(name1Store);
  const [name2, setName2] = useStore(name2Store);
  const [{ bobcat }] = useStore(name12Store);
  return (
    <>
      <h2>Hello {name1.state}!</h2>
      <h2>Hello {name1.randomCase}!</h2>
      <button onClick={() => setName1('Bilbo')}>Change Name</button>
      <button onClick={() => setName1.reverse()}>Reverse Name</button>

      <h2>Hello {name2.state}!</h2>
      <h2>Hello {name2.randomCase}!</h2>
      <button onClick={() => setName2('Bilbo')}>Change Name</button>
      <button onClick={() => setName2.reverse()}>Reverse Name</button>

      <button onClick={onResetAll}>Reset All</button>

      {bobcat && <h2>Hello, bobcat!</h2>}
    </>
  );
}

A smooth path to derived events

RxJS is the only way to smoothly scale to complex event-driven features.

StateAdapt sources extend RxJS observables, and StateAdapt stores directly reference RxJS observables and react to them:

tsx
// ...

const name1Store = adapt('Bob', { 
const name1Store = adapt('Loading...', { 
  adapter: nameAdapter,
  sources: { reset: onResetAll }, 
  sources: { 
    set: of('Bob').pipe(delay(3000)), // Any observable
    reset: onResetAll, 
  }, 
});
const name2Store = adapt('Kat', { 
const name2Store = adapt('Loading...', { 
  adapter: nameAdapter,
  sources: { reset: onResetAll }, 
  sources: { 
    set: of('Kat').pipe(delay(3000)), // Any observable
    reset: onResetAll, 
  }, 
});

// ...

Result:

tsx
const nameAdapter = createAdapter<string>()({
  reverse: name => name.split('').reverse().join(''),
  selectors: {
    randomCase: name =>
      name
        .split('')
        .map(c => (Math.random() > 0.5 ? c : c.toUpperCase()))
        .join(''),
  },
});

const onResetAll = source();

const name1Store = adapt('Loading...', {
  adapter: nameAdapter,
  sources: {
    set: of('Bob').pipe(delay(3000)),
    reset: onResetAll,
  },
});
const name2Store = adapt('Loading...', {
  adapter: nameAdapter,
  sources: {
    set: of('Kat').pipe(delay(3000)),
    reset: onResetAll,
  },
});

const name12Store = joinStores({
  name1: name1Store,
  name2: name2Store,
})({
  bobcat: s => s.name1 === 'Bob' && s.name2 === 'Kat',
})();

function DerivedEvents() {
  const [name1, setName1] = useStore(name1Store);
  const [name2, setName2] = useStore(name2Store);
  const [{ bobcat }] = useStore(name12Store);
  return (
    <>
      <h2>Hello {name1.state}!</h2>
      <h2>Hello {name1.randomCase}!</h2>
      <button onClick={() => setName1('Bilbo')}>Change Name</button>
      <button onClick={() => setName1.reverse()}>Reverse Name</button>

      <h2>Hello {name2.state}!</h2>
      <h2>Hello {name2.randomCase}!</h2>
      <button onClick={() => setName2('Bilbo')}>Change Name</button>
      <button onClick={() => setName2.reverse()}>Reverse Name</button>

      <button onClick={onResetAll}>Reset All</button>

      {bobcat && <h2>Hello, bobcat!</h2>}
    </>
  );
}

Automatic State Lifecycle

For state to be fully reactive, it cannot rely on external control code, including initialization and cleanup code.

StateAdapt stores know when they are being used, and automatically initialize and cleanup their state.

So, for this store:

tsx
const name1Store = adapt('Loading...', {
  sources: of('Bob').pipe(delay(3000)),
});

When this component mounts:

tsx
function AutomaticStateLifecycle() {
  const [name1, setName1] = useStore(name1Store);
  // ...

Only then will the state be initialized with Loading... and the observable created by of('Bob').pipe(delay(3000)) receive a subscription. After 3 seconds, the name will change to 'Bob'.

Then, when AutomaticStateLifecycle unmounts, as long as no other components are using name1Store, the state will be cleared.

Then, when AutomaticStateLifecycle mounts again, the state will be re-initialized to 'Loading...', the observable created with of('Bob').pipe(delay(3000)) will be subscribed to again, and after 3 seconds the name will change to 'Bob' again.

In situations where you want to keep the store permanently active, you can manually subscribe to its state$:

tsx
const name1Store = adapt('Loading...', {
  sources: of('Bob').pipe(delay(3000)),
});
name1Store.state$.subscribe();