@crux/slice v0.0.47-alpha
slice
Installation
npm install --save @crux/slice
createSlice
createSlice
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/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/slice';
interface CounterState {
count: number;
}
const initialState: CounterState = { count: 0 };
// This is where we define the parms for our actions
type CounterSlice = {
add: number;
subtract: number;
}
// Note we have to provide the `CounterSlice` so that we can infer the API.
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
Registering your slice
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,
},
});
Dispatching actions
Now you can dispatch your actions as normal:
import { actions } from 'counter/slice.ts';
dispatch(actions.add(5)); // dispatches { type: 'counter/add', payload: 5 }
Multiple parameters
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));
Optional parameters
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
Action type
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`
The 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
get
api
also contains a get
function, which will get the state for this specific slice:
api.get(); // { count: 0 }
api.get('count'); // 0
This is useful for if you want to provide the data to a different slice's async handlers. Here you can wrap your createSlice
function in order to inject another's API:
import { MultiplierAPI } from '../features/multiplier';
export function createCounterSlice(multilier: MultiplierAPI) {
return createSlice<CounterSlice>()(name, initialState, {
withMultiplier: (state, one) => ({ api }) => {
const multiple = multiplier.get('multiple');
if (multiple) {
add(one * multiple);
}
},
add: (state, one) => () => {
if (decisionsAPI.get()) {
}
},
(Note that's a horribly contrived example, but you get the point. Sometimes you might want access to some state that is not contained within the slice)
API Type
If you want to get the type of your API, you can do it like this:
export type CounterAPI = ReturnType<typeof counterSlice>['api'];
const counterSlice = createSlice<CounterSlice>()('counter', initial, {
add: (state, one) => ({
...state,
count: state.count + one,
}),
});
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 = ReturnType<typeof createAuthSlice>['api'];
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); // type inferred from 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();
}
}
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,
},
},
});
2 years ago
2 years ago