immer-ente
v2.2.0
Published
React state management with immer
Downloads
30
Maintainers
Readme
immer-ente
What is it?
This is a lightweight alternative to Redux, built using immer. It is heavily influenced by immer-wieder. It provides a "controller" framework with actions and state, but with minimal boilerplate.
Immer-ente is built on top of "immer", German for "always". "Ente" is German for "ducks", because this is meant to be a replacement for reDUX (see what I did there!?)
The biggest advantages over immer-wieder:
- React hooks support.
- State and actions are separate, making it easier to rehydrate state from the server.
- Easy unit testing for actions, without any React.
- Native typescript support.
How to use it
The basic idea is, you create a state object, and a set of actions. The immerEnte()
function returns a { Provider, Consumer, useController, useNewController }
object you can use in your application:
import immerEnte, { ControllerType } from 'immer-ente';
const initialState = {
age: 10,
};
// Create our controller with immerEnte
const {
// `Provider` will make the controller available to the whole render tree.
Provider: MyStateProvider,
// `useController` will get the controller from the Provider, or will
// throw an error if no Provider exists.
useController: useMyController,
// `useNewController` will create a new controller and use it, instead of
// finding one from the controller.
useNewController: useNewMyController,
} = immerEnte(
// Our initial state.
initialState,
// A function which returns an "actions" object. Actions have access to the
// current state via `getState()` and can update state with
// `updateState(draft => ...)`. See below for more details.
(updateState, getState) => ({
// A simple action.
incrementAge() {
updateState((state) => {
state.age++;
});
},
// Return a new state object in updateState() to replace
// the current state entirely.
setAge(age) {
updateState((state) => {
return { age, loading: false };
});
},
})
);
// ControllerType<> is a Typescript helper to retrieve the type signature for
// a controller.
type MyControllerType = ControllerType<typeof Provider>;
type MyActionsType = MyControllerType['actions'];
// Now we have two options for how we can use the contoller - via a Provider
export function Screen() {
// Somewhere higher up the render tree we need a `MyStateProvider`
// for `useMyController()` to work.
return (
<MyStateProvider defaultState={initialState}>
<MyComponent />
</MyStateProvider>
);
}
function MyComponent() {
// `useController` returned by immerEnte gives you access to state and actions
// in any component mounted under the `Provider` component.
const [state, actions] = useMyController();
return <button onClick={actions.incrementAge}>{state.age}</button>;
}
// Or we can create a controller via `useNewController()`
export function Screen2() {
const { state, actions } = useNewController();
return <button onClick={actions.incrementAge}>{state.age}</button>;
}
// Or we can do a combination of the two:
export function Screen3() {
const controller = useNewController();
return (
<MyStateProvider controller={controller}>
<MyComponent />
</MyStateProvider>
);
}
The immerEnte()
function takes two arguments; your initial state object,
and then a makeActions(updateState, getState)
function. updateState
is used
to update the current state. Actions can be synchronous or async:
const makeActions = (updateState) => ({
async myAction() {
updateState((draft) => void (draft.loading = true));
try {
await doAsyncThing();
} finally {
updateState((draft) => void (draft.loading = false));
}
},
});
updateState(fn)
is based on immer
, and can either update the state
directly as in the example above, or can return an entirely new state object:
const makeActions = (updateState) => ({
myAction() {
updateState((draft) => {
// Return a whole new state object
return { loading: false };
});
},
});
This does mean you need to be a little careful when using arrow functions:
const makeActions = (updateState) => ({
async myVeryBadAction() {
// Watch out! This is going to replace your whole state with 'true'.
updateState((draft) => (draft.loading = true));
},
async myGoodAction() {
// `void` is your friend, and will fix this for you.
updateState((draft) => void (draft.loading = true));
},
async myOtherGoodAction() {
// Or, use squiggly braces
updateState((draft) => {
draft.loading = true;
});
},
});
immerEnte()
returns a { Consumer, Provider, useController, makeController }
object. If Provider
is mounted somewhere in your react tree, then Consumer
and the useController()
hook can be used to get access to get access to state
and actions from anywhere in that tree. Provider
can be passed a defaultState
prop, which will let you pass in rehydrated state when doing server side rendering.
If you need access to the controller in the same top level component
immerEnte()
also returns a makeController()
, which can be used with class
based components, or can be used to test actions in isolation, without React
being involved. See below for more details.
Preventing unnecessary re-renders
If you're using the useController()
hook, you can provide a selector function
that will limit what data is returned, and prevent unnecessary re-renders:
function MyComponent() {
// Only re-render this component if `state.age` changes.
const [age] = useController((state) => state.age);
return <div>My age is {age}</div>;
}
If your selector returns an object, then it must return exactly the same object
on each invocation in order to avoid a re-render. If you have multiple values
you want to fetch, you can use an equality check when
calling useController(selector, isEqual)
:
import immerEnte, { isShallowEqual } from 'immer-ente';
function MyComponent() {
// Only re-render this component if `state.age` changes.
const [{age, name}] = useController(
(state) => { age: state.age, name: state.name },
isShallowEqual,
);
return (
<div>
My age is {age}, and my name is {name}.
</div>
);
}
This will not re-render in the case where isEqual
returns
true (in this case when the two objects are shallow-equal).
Writing unit tests
When writing tests, ideally we'd like to set the initial state to different values,
and then call actions to update the state, and make sure the state gets updated
the way we'd like. In a perfect world, we could do all of this without bothering
with react. immerEnte()
returns a makeController()
function which makes
this easy to do:
// createController.ts
import immerEnte from 'immer-ente';
const initialState = {
age: 10,
};
const { Provider, Consumer, useController, makeController } = immerEnte(
initialState,
(updateState, getState) => ({
incrementAge() {
updateState((state) => void state.age++);
},
})
);
export {
Provider as AgeProvider,
Consumer as AgeConsumer,
useController as useAgeController,
makeController,
};
Then you can write tests that create a new actions
and getState()
, with a
new initial state for every test:
// createControllerTest.ts
import { makeController } from './createController';
import { expect } from 'chai';
describe('age controller tests', function () {
it('should increment the age', function () {
const { actions, getState } = makeController({ age: 1 });
actions.incrementAge();
expect(getState().age).to.equal(2);
});
});
FAQ
Is this SSR safe?
Yes, you can use immer-ente with server-side rendering. When you create a controller, you pass in a "default state", however the actual state for a subtree is stored as a ref in the Provider, so creating two different instances of the Provider will effectively create two copies of the state.