LATEST UPDATE
Been using this a while now with great results
Base Class
import { useSyncExternalStore } from 'react';
export type Event = {
scope: any;
callback: () => void;
};
/**
* @public @name Store
* @description Used for storing global state data in react
*/
export default class Store {
private __state: object;
private __events: object;
private __customEvents: string[];
public key: string | undefined;
/**
* @public @constructor @name constructor
* @description Process called function triggered when component is instantiated (but not ready or in DOM, must call super() first)
*/
constructor(key?: string) {
this.key = key;
this.__state = new Proxy(this.defineState, this.defineHandler);
this.__events = {};
this.__customEvents = [];
}
/**
* @public @get @name defineState
* @description Basic define state in the store as an empty object that can be proxied
* @return the defined state
*/
get defineState(): object {
return {};
}
/**
* @public @get @name defineHandler
* @description Basic define handler to use with proxy, which bootstraps events in to detect changes
* @return the defined handler
*/
get defineHandler(): { get: (target: object, property: string) => any; set: (target: object, property: string, value: any) => boolean } {
return {
get: (target: object, property: string): any => {
this.emit(('getState.' + property) as keyof typeof this.__events, target[property as keyof typeof target]);
return target[property as keyof typeof target];
},
set: (target: object, property: string, value: any): boolean => {
(target[property as keyof typeof target] as typeof value) = value;
this.emit(('setState.' + property) as keyof typeof this.__events, target[property as keyof typeof target]);
return true;
},
};
}
/**
* @public @get @name state
* @description get the basic state of the store and emit an event
* @return the stores full state
*/
get state() {
this.emit('getState' as keyof typeof this.__events, this.__state);
return this.__state;
}
/**
* @public @set @name state
* @description OVERRIDDEN > You cannot set the full state of hte object, please define it in a child class
*/
set state(state: object) {
throw Error('Do not set state object directly, set props instead ' + Object.keys(state));
}
/**
* @public @get @name events
* @description combines default and custom events that may be added
* @return an array of the available states
*/
get events(): Array<string> {
return ['getState', 'setState', ...Object.keys(this.state).map((s) => 'getState.' + s), ...Object.keys(this.state).map((s) => 'setState.' + s), ...this.__customEvents];
}
/**
* @public @name addCustomEvent
* @description add a new custom event to use, when you say want ot override the handlers and add your own events in
* @param event the custom event to add to defaults
*/
addCustomEvent(event: string) {
const events = typeof event === 'string' ? [event] : event;
events.forEach((ev) => {
if (this.events.includes(ev)) return false;
this.__customEvents.push(ev);
});
}
/**
* @public @name addEventListener
* @description add a new event listener, this callback will be fired when the event is emitted
* @param event the custom event listener to add
* @param scope any custom scope to use in the callback
* @param callback the callback function to call when event is emitted
*/
addEventListener(event: string, scope: any, callback: () => void) {
// is this a recognised event?
if (!this.events.includes(event)) return;
// check structure and if already present
if (!this.__events[event as keyof typeof this.__events]) (this.__events[event as keyof typeof this.__events] as Array<Event>) = [];
if ((this.__events[event as keyof typeof this.__events] as Array<Event>).find((e) => e.scope === scope && e.callback === callback)) return;
(this.__events[event as keyof typeof this.__events] as Array<Event>).push({ scope, callback });
}
/**
* @public @name removeEventListener
* @description remove an event listener from the queue
* @param event the custom event listener you added to
* @param scope the custom scope you used to add the listener
* @param callback the callback function used to add the listener
*/
removeEventListener(event: string, scope: any, callback: () => void) {
// is this a recognised event?
if (!this.events.includes(event)) return;
// check structure and if already present
if (!this.__events[event as keyof typeof this.__events] || (this.__events[event as keyof typeof this.__events] as Array<Event>).length < 1) return;
if (!(this.__events[event as keyof typeof this.__events] as Array<Event>).find((e) => e.scope === scope && e.callback === callback)) return;
(this.__events[event as keyof typeof this.__events] as Array<Event>) = (this.__events[event as keyof typeof this.__events] as Array<Event>).filter(
(e) => e.scope !== scope || e.callback !== callback,
);
}
/**
* @public @name subscribe
* @description subscribe method to add an event and offer a way to remove as a return function, for use with subscribing in react
* @param event the custom event listener you added to
* @param callback the callback function used to add the listener
*/
subscribe(event: string, callback: () => void): Store['removeEventListener'] {
this.addEventListener(event, null, callback);
return () => this.removeEventListener(event, null, callback);
}
/**
* @public @name syncState
* @description sync all known properties using reacts useSyncExternalStore passed in at sync time from component function
* @param useSyncExternalStore the react instance of useSyncExternalStore calling this from within the function block of the component
* @param props the properties to sync with for automatic re-renders of the component after change
*/
syncState(props: string[] = []) {
if (!props || props.length < 1) props = Object.keys(this.state);
const cp = Object.keys(this.state);
props = props.filter((p) => cp.includes(p));
const syncstore: (callback: (callback: () => void) => any, snapshot: () => any) => any = useSyncExternalStore;
props.forEach((prop) => {
syncstore(
(callback: () => void) => {
return this.subscribe(`setState${prop ? '.' + prop : ''}` as keyof typeof this.__events, callback);
},
() => {
return prop ? this.state[prop as keyof typeof this.state] : this.state;
},
);
});
}
/**
* @public @name emit
* @description emit an event and run any listeners waiting
* @param event the event to emit
* @param data any data associated with the event
*/
emit(event: string, data: any) {
if (!this.__events[event as keyof typeof this.__events] || (this.__events[event as keyof typeof this.__events] as Array<Event>).length < 1) return;
(this.__events[event as keyof typeof this.__events] as Array<Event>).forEach((e) => e.callback.call<string, any, any>(e.scope, event, data, e.scope));
}
/**
* Update a nested deep property via dot notation ref and ensure event trigger
* @param property The property to update as a dot notation ref
* @param value The value to change to
*/
updateStoreProperty(property: string, value: any) {
const parts = property.split('.');
const root = parts.shift();
if (parts.length < 1) {
(this.state[root as keyof typeof this.state] as any) = value;
return;
}
const name = parts.pop();
let ref: any = this.state[root as keyof typeof this.state];
for (const key of parts) {
if (!ref[key as keyof typeof this.state]) ref[key as keyof typeof this.state] = {};
ref = ref[key as keyof typeof this.state] as any;
}
(ref[name as keyof typeof this.state] as any) = value;
(this.state[root as keyof typeof this.state] as any) = { ...(this.state[root as keyof typeof this.state] as any) };
}
/**
* clear all values in store and local storage (if applicable)
*/
clearAll() {
if (this.key) {
Object.keys(localStorage)
.filter((x) => x.startsWith(this.key || ''))
.forEach((x) => localStorage.removeItem(x));
}
this.__state = new Proxy(this.defineState, this.defineHandler);
}
/**
* clear specific property from state
*/
clear(property: string | string[]) {
if (typeof property === 'string') property = [property];
property.forEach((prop) => {
if (this.key) {
if (localStorage.getItem(`${this.key}.${prop}`) === null) return console.warn(`${prop} does not exist in local storage for object ${this.key}, skipping...`);
localStorage.removeItem(`${this.key}.${prop}`);
}
(this.state[prop as keyof typeof this.state] as any) = this.defineState[prop as keyof typeof this.state];
});
}
}
And your store example class, with some props being persisted to local storage and others not
import Store from '@/base/Store';
const key = `${import.meta.env.VITE_LOCAL_STORAGE_KEY}.store.example`;
export type ExampleUserType = {
id: string;
name: string;
email: string;
};
export type ExampleType = {
user?: ExampleUserType;
something: string;
another: string;
};
/**
* @public @name Example
* @description Used for capturing system state, things that track the state of the system as its used
*/
export default class Example extends Store {
/**
* @public @constructor @name constructor
* @description Process called function triggered when component is instantiated (but not ready or in DOM, must call super() first)
*/
constructor() {
super(key);
}
// map state values with defaults
get defineState() {
return {
user: JSON.parse(localStorage.getItem(`${key}.user`) || JSON.stringify(null)),
something: '',
another: ''
};
}
// cast state values to ensure type
get state(): ExampleType {
return super.state as ExampleType;
}
// extend the default handler
get defineHandler() {
return {
...super.defineHandler,
set: (target: object, property: string, value: any): boolean => {
// call base method
super.defineHandler.set(target, property, value);
// only local storage some, ignore these
if (['something', 'another'].includes(property)) return true;
// extend with persist system state to local storage
if (value === undefined) localStorage.removeItem(`${key}.${property}`);
else localStorage.setItem(`${key}.${property}`, JSON.stringify(value));
return true;
},
};
}
}
export const example = new Example();
export function useExampleStore(props: string[] = []) {
example.syncState(props);
return example;
}
Now we can use these in our component, or even in non classes....
import { useExampleStore } from '@/store/Example';
export default function Something() {
const exampleStore = useExampleStore(['user']); // subscribe to user property changes anyway, re rendering on change
function thing() {
// this will promote a re-render of the component automatically and sync to local storage on reload f5
exampleStore.state.user = { ...exampleStore.state.user, name: 'fdsfds' };
}
function thing2() {
// this will update global state property for the example class but this component will not re-render as we did not subscribe to its changes
exampleStore.state.something = 'fdsfds';
}
return (
<p>{exampleStore.state.user.name}</p>
<button onClick={() => thing()}>Click me</button>
<button onClick={() => thing2()}>Click me</button>
);
}
Use this in another class like a service handler or function thats not in a component...
import { example } from '@/store/Example';
class Service {
async doThing() {
// clear out values, making all subscribed components re render
example.clear(['template', 'history', 'sessionOverview']);
// this will promote a re-render of the component automatically and sync to local storage on reload f5
example.state.user = { ...example.state.user, name: 'fdsfds' };
// this will update global state property for the example class but this component will not re-render as we did not subscribe to its changes
example.state.something = 'fdsfds';
}
}
Like i say, it has cut down on a lot of re-renders, it uses the correct useSyncExternalStore so no prop drilling. Available anywhere in standard classes and components... is type supported, can sync to local storage and compartmentalizes your state storage into seperate classes keeping your store clean with common stuffs in base class
===========================================================
OLD POST
Leaving this here as a newbie with react thats spending some serious time lately... What is the thing with global state... or modular state in react? We have redux and we have mobx both with downsides, we have local state in useState... expected nativly to prop drill. We then have useContext which is well, prop drilling without the need to handshake each component.
After much playing, investigating looking at the fors and againsts, we are expected to useContext for native global/modular state that does not force render every component using ht econtext regardless of the object property being updated.
The proper answer here is useSyncExternalStore. Why are third party state engines not using this now https://react.dev/reference/react/useSyncExternalStore
redux tried to useContext and gave up, mobx seems to have its wrapped observable method. Just roll your own and useSyncExternalStore
The have a usable example on that page, but you could also roll your own storage engine with add remove event listening tied in a a proxy wrap around the state... then you can subscribe to the add/remove event
function subscribe(callback) {
System.addEventListener('setState.theme', null, callback);
return () => System.removeEventListener('setState.theme', null, callback);
}
You can then use in your component function
const theme: string = useSyncExternalStore(subscribe, () => System.state.theme);
Now when i do this
System.state.theme = theme
proxy is checking the added events, and then hitting the callback assigned to the property in the object.
useSyncExternalStore handles the re-render, it also ensures you only get a single event added, by using the subscribe method to remove and re-add as subscribe returns remove event.
This patterns gives me the ability to use any state engine, sync with say a browser API, and have a proper structured store (not a useContext verbose mess).
import Store from '@/base/Store';
/**
* @public @name System
* @description Used for capturing system state, things that track the state of the system as its used
*/
class System extends Store {
/**
* @public @constructor @name constructor
* @description Process called function triggered when component is instantiated (but not ready or in DOM, must call super() first)
*/
constructor() {
super();
}
// map state values with defaults
get defineState() {
return {
test: false,
theme: 'light',
};
}
// cast state values to ensure type
get state(): System['defineState'] {
return super.state as System['defineState'];
}
}
// export singleton instance
export default new System();
with a base class looking something like this
/* eslint-disable @typescript-eslint/no-explicit-any */
export type Event = {
scope: any;
callback: () => void;
};
/**
* @public @name Store
* @description Used for storing global state data in react
*/
export default class Store {
private __state: object;
private __events: object;
private __customEvents: string[];
/**
* @public @constructor @name constructor
* @description Process called function triggered when component is instantiated (but not ready or in DOM, must call super() first)
*/
constructor() {
this.__state = new Proxy(this.defineState, this.defineHandler);
this.__events = {};
this.__customEvents = [];
}
/**
* @public @get @name defineState
* @description Basic define state in the store as an empty object that can be proxied
* @return the defined state
*/
get defineState(): object {
return {};
}
/**
* @public @get @name defineHandler
* @description Basic define handler to use with proxy, which bootstraps events in to detect changes
* @return the defined handler
*/
get defineHandler(): object {
return {
get: (target: object, property: string): any => {
this.emit(('getState.' + property) as keyof typeof this.__events, target[property as keyof typeof target]);
return target[property as keyof typeof target];
},
set: (target: object, property: string, value: any): boolean => {
(target[property as keyof typeof target] as typeof value) = value;
this.emit(('setState.' + property) as keyof typeof this.__events, target[property as keyof typeof target]);
return true;
},
};
}
/**
* @public @get @name state
* @description get the basic state of the store and emit an event
* @return the stores full state
*/
get state() {
this.emit('getState' as keyof typeof this.__events, this.__state);
return this.__state;
}
/**
* @public @set @name state
* @description OVERRIDDEN > You cannot set the full state of hte object, please define it in a child class
*/
set state(state: object) {
throw Error('Do not set state object directly, set props instead ' + Object.keys(state));
}
/**
* @public @get @name events
* @description combines default and custom events that may be added
* @return an array of the available states
*/
get events(): Array<string> {
return ['getState', 'setState', ...Object.keys(this.state).map((s) => 'getState.' + s), ...Object.keys(this.state).map((s) => 'setState.' + s), ...this.__customEvents];
}
/**
* @public @name addCustomEvent
* @description add a new custom event to use, when you say want to override the handlers and add your own events in
* @param event the custom event to add to defaults
*/
addCustomEvent(event: string) {
const events = typeof event === 'string' ? [event] : event;
events.forEach((ev) => {
if (this.events.includes(ev)) return false;
this.__customEvents.push(ev);
});
}
/**
* @public @name addEventListener
* @description add a new event listener, this callback will be fired when the event is emitted
* @param event the custom event listener to add
* @param scope any custom scope to use in the callback
* @param callback the callback function to call when event is emitted
*/
addEventListener(event: string, scope: any, callback: () => void) {
// is this a recognised event?
if (!this.events.includes(event)) return;
// check structure and if already present
if (!this.__events[event as keyof typeof this.__events]) (this.__events[event as keyof typeof this.__events] as Array<Event>) = [];
if ((this.__events[event as keyof typeof this.__events] as Array<Event>).find((e) => e.scope === scope && e.callback === callback)) return;
(this.__events[event as keyof typeof this.__events] as Array<Event>).push({ scope, callback });
}
/**
* @public @name removeEventListener
* @description remove an event listener from the queue
* @param event the custom event listener you added to
* @param scope the custom scope you used to add the listener
* @param callback the callback function used to add the listener
*/
removeEventListener(event: string, scope: any, callback: () => void) {
// is this a recognised event?
if (!this.events.includes(event)) return;
// check structure and if already present
if (!this.__events[event as keyof typeof this.__events] || (this.__events[event as keyof typeof this.__events] as Array<Event>).length < 1) return;
if (!(this.__events[event as keyof typeof this.__events] as Array<Event>).find((e) => e.scope === scope && e.callback === callback)) return;
(this.__events[event as keyof typeof this.__events] as Array<Event>) = (this.__events[event as keyof typeof this.__events] as Array<Event>).filter(
(e) => e.scope !== scope || e.callback !== callback,
);
}
/**
* @public @name subscribe
* @description subscribe method to add an event and offer a way to remove as a return function, for use with subscribing in react
* @param event the custom event listener you added to
* @param callback the callback function used to add the listener
*/
subscribe(event: string, callback: () => void): Store['removeEventListener'] {
this.addEventListener(event, null, callback);
return () => this.removeEventListener(event, null, callback);
}
/**
* @public @name syncState
* @description sync all known properties using reacts useSyncExternalStore passed in at sync time from component function
* @param useSyncExternalStore the react instance of useSyncExternalStore calling this from within the function block of the component
* @param props the properties to sync with for automatic re-renders of the component after change
*/
syncState(useSyncExternalStore: (callback: (callback: () => void) => any, snapshot: () => any) => any, props: string[] = []) {
if (!props || props.length < 1) props = Object.keys(this.state);
const cp = Object.keys(this.state);
props = props.filter((p) => cp.includes(p));
props.forEach((prop) => {
useSyncExternalStore(
(callback: () => void) => {
return this.subscribe(`setState${prop ? '.' + prop : ''}` as keyof typeof this.__events, callback);
},
() => {
return prop ? this.state[prop as keyof typeof this.state] : this.state;
},
);
});
}
/**
* @public @name emit
* @description emit an event and run any listeners waiting
* @param event the event to emit
* @param data any data associated with the event
*/
emit(event: string, data: any) {
if (!this.__events[event as keyof typeof this.__events] || (this.__events[event as keyof typeof this.__events] as Array<Event>).length < 1) return;
(this.__events[event as keyof typeof this.__events] as Array<Event>).forEach((e) => e.callback.call<string, any, any>(e.scope, event, data, e.scope));
}
}
Needs more work but maybe something like this...
which can then be used simply with
System.syncState(useSyncExternalStore);
to force render on all store property changes, you then just use the values from state like
System.state.someprop
Or to only render on specific property changes
System.syncState(useSyncExternalStore, ['someprop', 'another']);
to re-render on only when those values change
its one line in your template then and you can choose how frugal with the re-renders you want to be