Search code examples
reactjstypescriptreact-hooksreact-context

How to control React Re-Renders with data coming from context


I have a provider which receives notifications on WebSocket from the server.

export const MessageProvider: React.FC<ProviderProps> = ({ children }) => {
    const [state, dispatch] = useReducer(reducer, messageInitialState);

    useEffect(() => {
        let cancelled: boolean = false;
        __get(cancelled, undefined);
        return () => {
            cancelled = true;
        }
    }, []);
    useEffect(__wsEffect, []);

    const get = useCallback<(cancelled: boolean, userId: number | undefined) => Promise<void>>(__get, []);
    const put = useCallback<(message: Message) => Promise<void>>(__put, []);

    const value = { ...state, get, put };
    return (
        <MessageContext.Provider value={value}>
            {children}
        </MessageContext.Provider>
    )

    ...

    function __wsEffect() {
        log('{__wsEffect}', 'Connecting');
        let cancelled: boolean = false;
        const ws = newWebSocket(async (payload) => {
            if (cancelled) {
                return;
            }

            const message: Message = payload as Message;
            await storageSet(message, __key(message.id));
            dispatch({ actionState: ActionState.SUCCEEDED, actionType: ActionType.SAVE, data: message });
        });

        return () => {
            log('{__wsEffect} Disconnecting');
            cancelled = true;
            ws.close();
        }
    }
}

Then in my Component, I use this context like this:

export const MessageListPage: React.FC<RouteComponentProps> = ({ history }) => {
    const { data, executing, actionType, actionError, get, put } = useContext(MessageContext);

    ...

    return (
        <IonPage id='MessageListPage'>
            <IonContent fullscreen>
                <IonLoading isOpen={executing && actionType === ActionType.GET} message='Fetching...' />
                {
                    !executing && !actionError && data && (
                        <div>
                            <IonList>
                                {
                                    groupBySender()
                                        .map(__data =>
                                            <div>
                                                <MessageListItem key={__data.sender} sender={__data.sender} messages={__data.messages} />
                                            </div>
                                        )
                                }
                            </IonList>
                        </div>
                    )
                }
            </IonContent>
        </IonPage>
    );
}

And what happens, is that every time data from context gets updated, this page re-renders, causing every MessageListItem to re-render as well.

The thing is that I want only to re-render those MessageListItem with whom the change could affect. Is this possible? I thought of using useMemo and memo, but I'm creating these objects dynamically and I can't figure it out how to do it.

Is this even possible?

I strive to achieve something like:

user a - x unread messages
user b - y unread messages
...

And when I click on a user, I want the item to expand and show those messages. But when I receive a notification, everything renders and the item reverts to not being expanded.


Solution

  • I had the exact same problem and it looks like that you can't control the re-render of children of the Provider. The solution with React.memo mentioned above won't work because context values are not props.

    There is a npm package growing in popularity that addresses this issue: https://www.npmjs.com/package/react-tracked

    You can read this article to get more options: https://blog.axlight.com/posts/4-options-to-prevent-extra-rerenders-with-react-context/

    I personally ended up using hooks with event listeners and local storage to get reactivity in the right components to have a global state without having to use useContext.

    Something like that:

        import React, { createContext, useEffect, useState } from "react";
        import ReactDOM from "react-dom";
    
        type Settings = {
            foo?: string;
        }
    
        const useSettings = () => {
            let initialSettings: Settings = {
                foo: undefined
            };
            try {
                initialSettings = JSON.parse(localStorage.getItem("settings")) as Settings;
            } catch(e) {};
    
            const [settings, setSettings] = useState<Settings>(initialSettings);
    
            useEffect(() => {
                const listener = () => {
                    if (localStorage.getItem("settings") !== JSON.stringify(settings)) {
                        try {
                            setSettings(JSON.parse(localStorage.getItem("settings")) as Settings);
                        } catch(e) {};
                    }
                }
    
                window.addEventListener("storage", listener);
                return () => window.removeEventListener("storage", listener);
            }, []); 
    
            const setSettingsStorage = (newSettings: Settings) => {
                localStorage.setItem("settings", JSON.stringify(newSettings));
            }
    
            return [settings, setSettingsStorage];
        }
    
        const Content = () => {
            const [settings, setSettings] = useSettings();
    
            return <>Hello</>;
        }
    
        ReactDOM.render(
            <Content settings={{foo: "bar"}} />,
            document.getElementById("root")
        );