@crux/redux-slice
v0.0.45-alpha
Published
## Installation
Downloads
13
Readme
redux-slice
Installation
npm install --save @crux/redux-slice
slice
slice
is a shorthand way of creating actions, reducers, and side-effects with minimal boilerplate. Normally, Redux leaves the side-effects up to you, and it's often messy, with no clear official direction. @crux/redux-slice
replaces the need for sagas, and ensures your code is succinct, easy to read, easy to test, and decoupled for maximum longevity.
The one downside to having all this functionality baked-in is that you have to define the parameters of your payloads separately. However, as you'll see, this is a small price to pay. Let's take a look at a simple example that covers all our needs (actions, reducers, and side-effects).
import { createSlice } from '@crux/redux-slice';
interface CounterState {
count: number;
}
const initialState: CounterState = { count: 0 };
// This is where we define the payloads for our actions
type CounterSlice = {
add: number;
subtract: number;
}
// Note we have to provide the `CounterSlice` so that crux can work out the API. It's an unfortunate limitation of
// TypeScript that we can't infer the type of `api` here. Hopefully future versions will allow this.
export const { actions, middleware, reducer } = createSlice<CounterSlice>()('counter', initialState, {
add: (state, num) => merge(state, {
count: state.count + num
}),
subtract: (state, num) => merge(state, {
count: state.count - num
}),
randomAddOrSubtract: (state, num) => async ({ api }) => {
const shouldAdd = Math.random() > 0.5;
if (shouldAdd) {
api.add(num);
} else {
api.subtract(num);
}
}
};
All types within the config are inferred from:
a) initialState
, which determines the shape of the state
, and
b) CounterSlice
, which determines the shape of the payload
In order to register your slice, you need to add both the reducer and the middleware that are returned from createSlice
:
import { middleware, reducer } from 'counter/slice.ts';
configureStore({
middleware: [middleware],
reducer: {
counter: reducer
}
});
Now you can dispatch your actions as normal:
import { actions } from 'counter/slice.ts';
dispatch(actions.add(5)); // dispatches { type: 'counter/add', payload: 5 }
You can provide multiple parameters to your actions like this:
type CounterSlice = {
add: [number, number];
}
const { actions, reducer } = createSlice<CounterSlice>()('counter', initial, {
add: (state, one, two) => ({
...state,
count: state.count + one + two
}),
});
The actions.add(1, 2)
action creator is fully typed, e.g. these will error:
dispatch(actions.add(1));
dispatch(actions.add('str'));
dispatch(actions.add(1, 2, 3));
You can also have optional parameters like this:
type CounterSlice = {
add: [number, number?];
}
// Or this
type CounterSlice = {
add: [number, number | void];
}
const { actions, reducer } = createSlice<CounterSlice>()('counter', initial, {
addOptional: (state, one, two) => ({
...state,
count: state.count + one + (two ?? 0)
}),
});
dispatch(actions.add(1)); // no error this time
dispatch(actions.add(1, 2)); // still works
If you need access to the action type (to use in a saga, for example), you can use the type
property on the action:
actions.add.type // `counter/add`
API
createSlice
also returns a handy api
object (which is what is provided in your async callback above), whereby dispatch
is called for you. This is great for reducing imports and coupling around your app. You still need to add the reducer and middleware as previously, but once that's done, you can now call this from anywhere without calling dispatch
:
import { api } from 'counter/slice.ts';
api.add(1, 2);
// Because this is just a reducer method, your store is now updated synchronously:
console.log(store.getState().counter.count); // 3
If you want to get the type of your API, you can do it like this:
import { ApiOf } from '@crux/redux-slice';
export type CounterAPI = ApiOf<CounterSlice>;
Side-effects
createSlice
has another trick up its sleeve. Redux has many solutions for side-effects, but most of them fall short in a number of areas. Either TypeScript support is not great, or it results in messy, hard-to-reason-about code, or the API is polarising.
createSlice
offers a new way to manage side-effects, from within the slice definition. If you return a function instead of a new state object, createSlice
will call it with an object that contains an api
, the very same api
as in the section above. It's fully-typed, and makes for extremely simple side-effect logic.
Let's look at a simple auth example:
export interface AuthState {
user: User | null;
}
const initialState: AuthState = {
user: null,
}
type AuthSlice = {
login: [string, boolean?];
loginFailure: void;
loginSuccess: User;
logout: void;
logoutSuccess: void;
}
export type AuthApi = ApiOf<AuthSlice>;
export function createAuthSlice(name: string, auth: AuthHttp) {
return createSlice<AuthSlice>()(name, initialState, {
login: (state, email, remember = false) => async ({ api }) => {
const user = await auth.login(email, remember);
if (user) {
api.loginSuccess(user); // fully typed acording to the `AuthSlice` definition above
} else {
api.loginFailure();
}
},
loginFailure: (state) => ({
user: null,
}),
loginSuccess: (state, user) => ({
user,
}),
logout: () => async ({ api }) => {
const success = await auth.logout();
if (success) {
api.logoutSuccess();
}
},
logoutSuccess: (state) => ({
user: null,
}),
});
}
The first thing to notice here is that we're wrapping createSlice
with our createAuthSlice
function, which provides the auth
HTTP API for us to use. This dependency injection means that our code is portable and testable.
Secondly, we have two types of action here:
- Reducer actions - these return a new
state
(loginFailure
,loginSuccess
,logoutSuccess
). - Side-effect actions - these return a function that accepts
({ api })
and that performs side-effects, which in this case ultimately call reducer actions to update the state.
Notice how clean the code is. Because all the dependencies are injected, and their types are inferred, our code becomes a simple expression of logic. It can be extracted to individual testable functions too, for example like this:
...
login: (state, email, remember = false) => async ({ api }) => login(api, auth, email, remember),
});
}
function login(api: AuthApi, authHttp: AuthHttp, email: string, remember?: boolean) {
const user = await auth.login(email, remember);
if (user) {
api.loginSuccess(user);
} else {
api.loginFailure();
}
}
This function can now be extracted and unit-tested.
Using merge
to reduce boilerplate and errors
Other than the boilerplate involved with creating actions, the other thing that Redux users often hate is "spread hell":
{
...state,
nested: {
...state.nested: {
nested: {
...state.nested.nested
nested: payload
},
},
},
},
It's really ugly and it's easy to make a mistake. Apart from not having a state that is too nested, one solution to this is to use an immutable merge
function. RTK chose immer, but this comes with the slightly dirty feeling of modifying function parameters, and I know that many people will be adding // eslint-disable ...
on every slice/reducer they create.
crux
provides a different solution, which looks like this:
import { merge } from '@crux/utils';
type CounterSlice = {
add: number;
subtract: number;
}
export const { actions, reducer } = createSlice<CounterSlice>()('counter', initialState, {
add: (state, num) => merge(state, {
count: state.count + num
}),
subtract: (state, payload) => merge(state, {
count: state.count - num
}),
});
This tidies things up a little, and ensures that all your updates are immutable, even with nested properties. So, instead of this:
{
...state,
nested: {
...state.nested: {
nested: {
...state.nested.nested
nested: payload
},
},
},
},
You can do this:
merge(state, {
nested: {
nested: {
nested: payload
},
},
},)