-
Notifications
You must be signed in to change notification settings - Fork 7
Slicing the State
A powerful feature of reactive-state is the ability to create store slices that point to a smaller part of the global state. A slice is itself of type Store
(the same type as the global store but using a different generic type argument) and exposes the exact same interface as the global (root) Store.
Slices are used to divide your usually larger root state into smaller pieces that can then be used by individual and independent code parts or modules. They also help simplify your reducer logic, as immutable operations on any updated property (which requires updating all levels of nested properties up to the root state, see the Redux docs for details!) are hard to grasp and often a source of mistakes for beginners.
If you know Redux, you will probably have heard or reducer composition using the combineReducer
function. In Reactive-State the slices replace the need for reducer composition.
import { Store, Reducer } from "reactive-state";
// Define a type of our global (root) state
interface AppState {
counter: number;
}
// create an initial state to start with
const initialState: AppState = {
counter: 0
};
// create the global (root) store
const appStore = Store.create(initialState);
// create an action
const incrementAction = new Subject<number>();
// define our reducer - note how we return a new object instance instead of modifying the existing
// state instance (that is an immutable operation)
const incrementReducer: Reducer<AppState, number> = (state, payload) => {
const newState = { ...state, counter: counter.state + payload };
return newState;
}
appStore.addReducer(incrementAction, incrementReducer);
appStore.watch().subscribe(state => console.log(state));
// dispatch actions
incrementAction.next(1);
incrementAction.next(2);
The above example can be modified to obtain a slice (a sub-store) counterStore
from the root state, and we can define the reducer differently:
const counterStore: Store<number> = appStore.createSlice("counter");
const incrementReducer: Reducer<number, number> = (state, payload) => state + payload;
// we add the reudcer now to the slice
counterStore.addReducer(incrementAction, incrementReducer);
Besides the changes to the reducer and the creation of the counterStore, the above code can stay intact and produces the exact same results.
Note: While in the sliced example, the "counter"
string passed as argument to .createSlice()
might look like a magic string constant to you, it is actually not: The argument is of type keyof AppState
and the TypeScript compiler will complain if you enter any string in there that is not a valid property name of AppState
. This makes it totally safe for refactorings!
You can create slices of slices and so forth. For example, the following example will work:
const initialState = {
slice1: {
slice2: {
slice3: {
counter: 0
}
}
}
};
const appStore = Store.create(initialState);
const counterStore = appStore
.createSlice("slice1")
.createSlice("slice2")
.createSlice("slice3");
Note: Since we use strict typing and TypeScript has excellent type inference, the type of counterStore
in the above example is Store<{ counter: number }>
.
The createSlice()
function does accept a second optional argument which specifies an initial state object to initialise the slice with, and an optional third argument that reflects a state/value that should be set to the slice property when the slice store is destroyed using the .destroy()
function:
interface AppState {
// note how we make counter an optional property
counter?: number;
}
// if we pass no argument to Store.create(), the empty object {} is used as initial state!
const appStore: Store<AppState> = Store.create();
appStore.select().subscribe(state => console.log(state.counter)); // output: undefined
// initialise the counter property on the root state with 0 and set it to null after destroying the slice
let counterStore: Store<number> = appStore.createSlice("counter", 0, null); // output: 0
counterStore.destroy(); // output: null
// If we want to remove the key "counter" completely from the state object, you can specifiy a
// string constant "undefined" (= typeof undefined) as third argument:
counterStore = appStore.createSlice("counter", 0, "undefined"); // output: 0
counterStore.destroy(); // output: undefined
This pattern is very useful if you want to delegate initial state creation to a module that exclusively operates on a slice of the state, and to clear the slice of the state after the module is unloaded. That way the state will look like it has never been touched after unloading the module!
More on the .destroy()
function: When calling .destroy()
on the state the following things happen:
- The root of the slice (in our example the counter property on the root state) is reset to the third argument (=cleanup state) given in the
.createSlice()
function when creating the slice store. If no argument is given as cleanup state, the state is left as it is at the time of destruction. - Any reducers added to this slice instance (not to the root store and not to other slice store instances pointing to the same slice) will be unsubscribed (will not react to the registered actions anymore)
- All the Observables obtained through that slices
.select()
or.watch()
function will complete, which results in all subscriptions to those observables being unsubscribed.
It is thus always advised you use .destroy()
when you are done with the slice, as this performs all proper cleanups. If you don't call .destroy()
you must track subscriptions created with the .subscribe()
function yourself and unsubscribe them properly in order to avoid memory leaks (this is something common with RxJS).