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
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:
function SimpleStateAdapt() {
const [name, setName] = useAdapt('Bob');
return (
<>
<h2>Hello {name.state}!</h2>
<button onClick={() => setName('Bilbo')}>Change Name</button>
</>
);
}
2. Add Reducers
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:
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
:
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:
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:
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:
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:
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:
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:
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:
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:
const booleanAdapter = createAdapter<boolean>()({
toggle: state => !state,
});
And later decide to have multiple boolean properties of a larger state object:
type State = {
isActive: boolean;
isVisible: boolean;
};
You can reuse the simple boolean logic by creating a joined adapter that extends it:
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:
// ...
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:
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:
// ...
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:
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:
// ...
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:
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:
const name1Store = adapt('Loading...', {
sources: of('Bob').pipe(delay(3000)),
});
When this component mounts:
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$
:
const name1Store = adapt('Loading...', {
sources: of('Bob').pipe(delay(3000)),
});
name1Store.state$.subscribe();