Angular provides useful feature of services where we encapsulates all our Business Logic (BL) [back-end integration] inside the services. BL does includes persistence of state/data that would probably be meant for other components too. With increased components hierarchy, we tend to increase services that are associated with them which leads to application getting bloated and data communication between services and counter components gets messy.
To fix this problem, we need opinionated state management and vast majority of solutions are already available in OSS market like NgRx, Ngxs, Akita, Elf, RxAngular
etc. But this solution comes with a cost which is their associated learning curve and boilerplate code just to have its initial setup hooked into our application.
To reduce this pain and get your system ready up (bare metal) and setup-ed in less time frame, I have created a dead simple state management solution in just less than 50 lines of code.
Yes, you hear it right, its just less than 50 lines of code. 😉
I'm not gonna say that this is a full fledged state management solution that advanced libraries does. This is a bare metal need of state management which can suffice a need of many developers in their day to day task. For obvious reason when your task and need is more complex, one should consider using an opinionated state management libraries as stated above since they are tested well within the community and are scale-able enough.
So the basic fundamental of state management is to cache recurring data which is to be passed along a lot of component hierarchy. Input/Props drilling is one the issue where state management methodology like flux comes to resort. A central global store that will act as hydration of data to our components and probably act as single source to truth for many entities in your application.
So certain check list needs to be considered when implementing state management that is refer below.
✅ Central store for most of the entities (single source of truth).
✅ The store should be reactive (pushing instead of polling. Polling can be additional feature too).
✅ Select a certain slice of cached data.
✅ Update/Destroy the cached entity.
✅ No mutation for cached entity outside of reducer.
The state management solution that I'm gonna present is CRUD based. And this is gonna suffice 70-80% of use cases.
The syntax for function wrapper is gonna remind you of slice from Redux Toolkit.
Create a wrapper function
We are going to create a wrapper function that will help with the initial implementation of slice.
export function createSlice(opts) {
}
Setting up Initial Data (🇨RUD)
This is the phase where we are going to create a slice with the initial state/data.
Typings for createSlice Options would look like:
export type CreateSliceOptions<T> = {
initialValue: T;
};
Using this type inside the function wrapper.
export function createSlice<T>(opts: CreateSliceOptions<T>) {
let _value = opts.initalValue;
}
Reading the value from inside the slice (C🇷UD)
We need to expose a function from inside the createSlice wrapper that will fetch us the current state inside the slice.
Typings for createSlice Instance would look like:
export type CreateSliceInstance<T> = {
...
/**
* Returns the current value of slice
*/
getValue: () => T;
...
}
Using this type inside the function wrapper.
return {
...,
getValue: () => _value;
}
Updating the data inside of slice (CR🇺D)
In order to update the slice, we will expose a method called update
that will update the value inside the slice.
Let's add the update
typing to the CreateSliceInstance
.
export type CreateSliceInstance<T> = {
...
/**
* Callback to update the value inside the slice.
*/
update: (state: Partial<T>) => void;
...
}
Implementing the update method in the slice wrapper.
return {
...,
update: (state: Partial<T>) => {
_value = state;
}
}
How come this approach would be reactive? How would I subscribe to the changes I performed via
update
method?
In order to make our slice reactive, we need to re-adjust some implementation inside the createSlice
wrapper, but although the typings will remain same.
NOTE: For now, the implementation only supports objects, and not primitive like string, number, boolean etc.
function createSlice<T>(opt: CreateSliceOptions<T>): CreateSliceType<T> {
let _ob$ = new BehaviorSubject<T>(null);
let _value = new Proxy(opt.initialValue ?? {}, {
set: (target, property, value, receiver) => {
const allow = Reflect.set(target, property, value, receiver);
_ob$.next(target as T);
return allow;
},
});
return {
valueChanges: _ob$.asObservable().pipe(debounceTime(100)),
getValue: () => _ob$.getValue(),
update: (state: Partial<T>) => {
Object.keys(_value).forEach(key => {
if (state.hasOwnProperty(key)) {
_value[key] = state[key];
}
});
},
}
}
WOW, there are a lot of changes. Let's discuss them step-by-step:
- We have created a BehaviorSubject that will emit the value inside it whenever we trigger
next
on it. - Instead of assigning initalValue directly to
_value
, we will create a new Proxy object, where we will override varioushandler
methods on thetarget
object. To read more about Proxy Pattern, refer this. - We will override the
set
method of the target object i.e.initialValue
and will emit an new value, whenever a target is mutated. - For the
update
method, we will iterate over to the properties of the supplied state as param to update method and check if the property key in the state belongs to initialValue object and updating the_value[key]
. The usage of hasOwnProperty will help us eradicate any miscellaneous (unknown) property from the slice's state. - We have use
debounceTime
in order to aggregate (iteration inside theupdate
method) the changes in a certain time frame i.e. 100ms and will emit the target finally.
I hope this all makes sense to you all till now.
Deleting/Destroying the value inside the slice (CRU🇩)
When the slice is no longer in need, we can simply destroy the slice by calling the destroy
on it.
Typing and implementation for destroy
would be like:
...
/**
* Destroy the slice and closure data associated with it
*/
destroy: () => void;
...
return {
...,
destroy: () => {
_ob$.complete();
// In case the target reference is used somewhere, we will clear it.
_ob$.next(undefined);
// Free up internal closure memory
_value = undefined;
_ob$ = undefined;
},
...
}
Resetting the slice's state (with intialValue)
There might be possibility where you might want to reset the state inside the slice.
Typings and implementation of reset
would be like:
...
/**
* Reset the data with initial value
*/
reset: () => void;
...
return {
...,
reset: () => {
const {initialValue} = opt;
Object.keys(initialValue).forEach(key => {
_value[key] = initialValue[key];
});
},
...
}
Complete Implementation
BONUS
If we see the implementation properly, the mutation can be possible via fetching target value from either getValue
or valueChanges
observable subscription. Although the mutation should not be happening outside the reducer (i.e. inside the slice context only).
We can fix this behaviour by wrapping the value inside the Object.freeze(target)
. Here is the revised implementation for getValue
and valueChanges
respectively.
return {
...,
valueChanges: _ob$.asObservable().pipe(
debounceTime(100),
map(value => Object.freeze(value)),
),
getValue: () => Object.freeze(_ob$.getValue()),
...
}
Final thoughts
Thanks you for staying till here. You probably have learned something new today and that's a better version of you from yesterday's.
If you like this article, do give it a like or bookmark it for future reference. And if you feel there's need for some improvisation, do let me know in the comments. Would love to learn together.
Top comments (0)