What is the proper way to do global state?
Asked Answered
A

4

34

I have an app, and I want the app component to hold the current logged in user. I have the following routes and components.

Basically, every component in my app will make use of the user object. Sure, I can pass the user as props to each and every component, but that is not elegant. What is the proper React way of sharing the user prop globally?

const App = () => {
    const [user, setUser] = useState(null);

    return (
        <Router>
            <div className="app">
                <Topbar />
                <Switch>
                    <Route path="/login" exact component={Login} />
                    <Route path="/home" exact component={Home} />
                    <Route path="/" exact component={ShopMenu} />
                    <Route path="/orders">
                        <Orders />
                    </Route>
                    <Route path="/wishlist" exact component={Wishlist} />
                    <Route path="/wallet" exact component={Wallet} />
                    <Route path="/cart" exact component={Cart} />
                </Switch>
                <BottomBar />
            </div>
        </Router>
    );
};
Agustinaah answered 22/10, 2021 at 10:33 Comment(1)
You can use react context API or any other state management library like Redux.Tying
P
65

Take a look at React Context and more specifically useContext as you're using hooks.

The idea of context is exactly that - for you to be able to share updateable state to the descendants without having to pass it from component to component (the so called "prop-drilling").

export const UserContext = React.createContext(null);

const App = () => {
  const [user, setUser] = useState(null);

  return (
    <Router>
      <div className="app">
        <UserContext.Provider value={{ user: user, setUser: setUser }}>
          <Topbar />
          <Switch>
             {/* <routes>... */}
          </Switch>
          <BottomBar />
        </UserContext.Provider>
      </div>
    </Router>
  );
};

Then, in your component:

import { UserContext } from './app';

const Topbar = () => {
  const { user, setUser } = useContext(UserContext);

  // use `user` here
};

If you want to have access to the setter (setUser) you can pass it through the context as well.

Parham answered 22/10, 2021 at 10:48 Comment(11)
Gives 'UserContext' is not defined in component (file)Stupor
@Stupor Well, you need to import it from the place you have defined it. Export UserContext where you define it and then import it on usage.Parham
@Parham Please update your answer to include the UserContext import/export, and also demonstrate the passing and usage of setUser.Laurenalaurence
@Laurenalaurence The question is not about teaching people how to import/export modules. Obviously, you have to know basic JavaScript to use React.Parham
@Parham true, but it is almost always the case that a description of code, as in your answer to GooDeeJAY, is inferior to code itself.Laurenalaurence
This makes all of your components non-reusable unless you drag your custom context around everywhere.Sorgo
@ToddSmith Well, that's how react context works, not sure why you target it against my example specifically. Furthermore, I am not sure what you're implying with your statement. The context is provided once at the almost-top of the hierarchy. You don't need to drag anything anywhere. The reusability is absolutely the same.Parham
Why Is this not giving a curcular import problem?Expedient
@UmbertoFontanazza This is an example and is structured this way for simplicity and brevity. The context should be exported into a separate file and referenced from consumers. No need to nit-pick it that hard.Parham
@Parham mine was not supposed to be a criticism, I tried your code and it works fine. Was just wondering why because I came up to this same conclusion on my own but I discarded it before trying it (and I was wrong). Most likely is some React magic I'm unaware ofExpedient
I had tried with Context, it works but problem comes where there is no useSelector for context. Wherever the useContext is used(As a global state, the whole app) will re-render when any part of context state change. useSyncExternalStore is the best option.Andiron
P
3

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

Portillo answered 5/7, 2023 at 6:39 Comment(0)
E
1

Simple React Global State with Hooks (Observer Design Pattern)

However "React way" was asked for, it might be helpful for all of you who seek for a simple and robust alternative.

codesandbox example

The concept is based on Yezy Ilomo's article. It just has been put into a working condition and been renamed the variables & functions to be more self-explaining.

I use it in production, in multiple projects, with this particular implementation directly (not Yezy's npm package) and it works like a charm.

Endamoeba answered 3/12, 2022 at 20:59 Comment(1)
I use the following pattern codesandbox.io/s/react-global-state-example-t1cm56. Here you can get and set the store from anywhere in your app without being in a react component. If you go a step farther you can prevent re renders by shallow comparing. I made a github here github.com/stevekanger/react-superstore/blob/main/src/index.tsTraumatize
A
0

Hope this helps somebody. I had created a single file npm package just using useSyncExternalStore and functional custom hooks. I could able to minimize the import size to just 635 bytes (gzipped).

https://www.npmjs.com/package/native-state-react

npm i native-state-react

import and Add <Root /> as a component in the top level.

then, import and use useSelector to get the desired global state slice.

const [name,setState] = useSelector(s=>s.name);

if name not found in global state, it will return undefined.

And you can update the name like this..

setState({name:"Will"});

The implementation is a bit similar to Paul Smith's answer.

Andiron answered 7/7 at 13:13 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.