Reputation: 2280
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.
Upvotes: 0
Views: 641
Reputation: 1383
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")
);
Upvotes: 1
Reputation: 980
You should use React.useMemo because each time your provider re-render the constants value gets a new reference so the consumer re-rendred.
change this
const value = { ...state, get, put };
to
const value = React.useMemo(() => { ...state, get, put }, [state,get,put])
useMemo will not recalculate a new value until state or get or put changed
Upvotes: 2
Reputation: 156
This is a drawback to using contexts, as referenced here in the react docs. https://reactjs.org/docs/context.html#caveats
To prevent the list items from re-rendering, you could explore wrapping those components with React.memo, which referentially compares props from one render to the next. The drawback is this can become complex if the props contain objects, because they will be referentially different if you re-create the props each time
With typescript, you could define an equals method to compare the properties of objects to decide equality and compare them in the areEqual callback parameter of memo to determine if your component should re-render. This may not be the solution you were hoping for but hopefully this provides a work-around if you really need the performance boost.
Upvotes: 0