React hooks best practice storing refs to third party libraries - javascript

I'm creating a wrapper hooks library around pusher-js to publish into the wild. For each hooks (i.e. useChannel, usePresenceChannel, useTrigger), I need to keep a reference to the Pusher instance, i.e. new Pusher() stored in a Context Provider. I am allowing third party auth to be passed in so I need to create the Pusher instance on the fly. I'm unsure whether I should be storing this in a useState or useRef.
The eslint-plugin-react-hooks rules complain with various combinations of useState and useRef to store this. I'm also seeing undesirable side effects when trying to clean up correctly with each. I'm unsure what's considered best practice.
Here's a simplified implementation with the important details. See comments 1. 2. and 3. below for my questions.
// PusherContext.jsx
export const PusherContext = React.createContext();
export function PusherProvider({key, config, ...props}){
// 1. Should I store third party libraries like this?
const clientRef = useRef();
// vs const [client, setClient] = useState();
// when config changes, i.e. config.auth, re-create instance
useEffect(() => {
clientRef.current && clientRef.current.disconnect();
clientRef.current = new Pusher(key, {...config});
}, [clientRef, config]);
return <PusherContext.Provider value={{ client }} {...props} />
}
// useChannel.js
export function useChannel(
channelName,
eventName,
callback,
callbackDeps
){
const { client } = useContext(PusherContext);
const callbackRef = useCallback(callback, callbackDeps);
// 2. Or should I be using state instead?
const [channel, setChannel] = useState();
// vs. const channelRef = useRef();
useEffect(() => {
if(client.current){
const pusherChannel = client.current.subscribe(channelName);
pusherChannel.bind(eventName, callbackRef.current);
setChannel(pusherChannel);
}
// can't cleanup here because callbackRef changes often.
// 3. eslint: Mutable values like 'client.current' aren't valid dependencies because mutating them doesn't re-render the component
}, [client.current, callbackRef])
// cleanup for unbinding the event
// re-bind makes sense with an updated callback ref
useEffect(() => {
channel.unbind(eventName)
}, [client, channel, callbackRef, eventName]);
// cleanup for unsubscribing from the channel
useEffect(() => {
clientRef.unsubscribe(channelName);
}, [client, channelName])
}
Any advice, past examples or patterns are greatly appreciated as I want to nail this one!

I'd use the ref to hold a new instance of Pusher as recommended by Dan.
You don't need to clean up initially by doing null check and disconnect (clientRef.current && clientRef.current.disconnect()) in the inside effect because whenever the useEffect is run, React will disconnect when you handle it in the return statement.
export function PusherProvider({ key, config, ...props }) {
// 1. Should I store third party libraries like this?
const clientRef = useRef();
// vs const [client, setClient] = useState();
// when config changes, i.e. config.auth, re-create instance
// useEffect(() => {
// clientRef.current && clientRef.current.disconnect();
// clientRef.current = new Pusher(key, { ...config });
// }, [clientRef, config]);
// Create an instance, and disconnect on the next render
// whenever clientRef or config changes.
useEffect(() => {
clientRef.current = new Pusher(key, { ...config });
// React will take care of disconnect on next effect run.
return () => clientRef.current.disconnect();
}, [clientRef, config]);
return <PusherContext.Provider value={{ client }} {...props} />;
}
For the second case, I tried as much to write suggestions in-line below.
The gist is that, un/subscription are related events, thus should be handled in the same effect (as it was the case for PusherProvider).
// useChannel.js
export function useChannel(channelName, eventName, callback, callbackDeps) {
const { client } = useContext(PusherContext);
const callbackRef = useCallback(callback, callbackDeps);
// 2. Or should I be using state instead?
// I believe a state makes sense as un/subscription depends on the channel name.
// And it's easier to trigger the effect using deps array below.
const [channel, setChannel] = useState();
useEffect(() => {
// do not run the effect if we don't have the Pusher available.
if (!client.current) return;
const pusherChannel = client.current.subscribe(channelName);
pusherChannel.bind(eventName, callbackRef.current);
setChannel(pusherChannel);
// Co-locate the concern by letting React
// to take care of un/subscription on each channel name changes
return () => client.current.unsubscribe(channelName);
// Added `channelName` in the list as the un/subscription occurs on channel name changes.
}, [client.current, callbackRef, channelName]);
// This.. I am not sure... πŸ€”
// cleanup for unbinding the event
// re-bind makes sense with an updated callback ref
useEffect(() => {
channel.unbind(eventName);
}, [client, channel, callbackRef, eventName]);
// Moved it up to the first `useEffect` to co-locate the logic
// // cleanup for unsubscribing from the channel
// useEffect(() => {
// clientRef.unsubscribe(channelName);
// }, [client, channelName]);
}

Related

Should a setState function be a dependency of useEffect when passed via hook

So, I've stumbled upon this weird situation:
I have a global React Context provider, providing a global state, like so
const Context = createContext();
const ContextProvider = ({children}) => {
const [state, setState] = useState('');
return <Context.Provider value={{state, setState}}>{children}</Context.Provider>
}
const useMyState = () => {
const {state, setState} = useContext(Context);
return {
state,
setState
}
}
const Component = () => {
const {setState} = useMyState();
useEffect(() => {
elementRef.addEventListener('click', () => {
setState('someState');
});
return () => {
elementRef.removeEventListener('click', () => null);
}
},[])
return <>
// ...
</>
}
eslint suggests that my setState should be added to the useEffect's dependency array,
useEffect(() => {
elementRef.addEventListener('click', () => {
setState('someState');
});
},[setState])
I'm guessing that this might be somehow related to the destructuring of the context inside the useMyState.ts file
but that feels a bit weird and non-intuitive...
my question is is the setState really required inside the dependency array? and if so, why?
my question is is the setState really required inside the dependency array?
No, it isn't, but ESLint doesn't know that, because it has no way to know that the setState member of the context object you're using is stable. You know that (because the setter is guaranteed to be stable by useState, and you're passing it verbatim through context and your useMyState hook), but ESLint doesn't know that.
You can add it as a dependency to make ESLint happy (it won't make any difference if you're already providing an array, because the setter never changes; see below if you're not providing an array), or you can put in a comment to tell ESLint to skip checking that code, or you can turn the rule off (but it's very easy to miss out dependencies, so be careful if you do).
(If you're not providing an array [because you want the effect to run after every render], adding an array with the setter in it will stop that from happening, so you'll want to go with the option of disabling the ESLint error for that one situation. Or there are icky solutions like using a ref with an ever-increasing number value in it. :-) )
There is a problem with that code, though. It's repeatedly adding new event listeners to the element without ever removing them, because with no dependency array, the useEffect callback is called every time the component renders, and you're creating a new event handler function every time, so they'll stack up.
So you'll need to make elementRef.current a dependency, and you'll need a cleanup callback:
const Component = () => {
const {setState} = useMyState();
useEffect(() => {
const handler = () => {
setState("someState");
};
const element = elementRef.current;
// Note βˆ’βˆ’βˆ’βˆ’βˆ’βˆ’βˆ’βˆ’βˆ’βˆ’βˆ’βˆ’βˆ’βˆ’βˆ’βˆ’βˆ’βˆ’^^^^^^^^
element.addEventListener("click", handler);
return () => {
element.removeEventListener("click", handler);
};
}, [elementRef.current]); // <== Optionally add `setState` to this
return <>
// ...
</>;
};

React Hooks - Global state without context

I am implementing a hook "useUserPosts", which is supposed to be used in several routes of my application.
As I already have a context "PostsContext" which re-renders my Cards when data changes (totalLikes, totalComments, descriptions, ...), I have decided to avoid creating another one called "UserPostsContext" which purpose is to return the user posts array.
I know, why not to use the PostsContext instead?...
The answer is that, in PostsContext, to avoid performance issues, I am storing a map (key, value), in order to get/update the posts dynamic data in O(1), something which is only useful in my components (so, it is used to synchronize Cards basically)
Is it possible/a common practice in React to create hooks that handles global states without using the Context API or Redux?
I mean, something like
// Global State Hook
const useUserPosts = (() => {
const [posts, setPosts] = useState({});
return ((userId) => [posts[id] ?? [], setPosts]);
})();
// Using the Global State Hook
function useFetchUserPosts(userId) {
const [posts, setPosts] = useUserPosts(userId);
const [loading, setLoading] = useState(!posts.length);
const [error, setError] = useState(undefined);
const cursor = useRef(new Date());
const hasMoreToLoad = useRef(false);
const isFirstFetch = useRef(true);
const getUserPosts = async () => {
// ...
}
return { posts, loading, error, getUserPosts };
}
Note: my purpose with this is to:
1. Reproduce some kind of cache
2. Synchronize the fetched data of each stack screen that is mounted in order to reduce backend costs
3. Synchronize user posts deletions
Even if I think creating a new global state is the best solution, if you really want to avoid it, you could create your own as follow :
export class AppState {
private static _instance: AppState;
public state = new BehaviorSubject<AppStateType>({});
/**
* Set app state without erasing the previous values (values not available in the newState param)
* */
public setAppState = (newState: AppStateType) => {
this.state.next({ ...this.state, ...newState });
};
private constructor() {}
public static getInstance(): AppState {
if (!AppState._instance) {
AppState._instance = new AppState();
}
return AppState._instance;
}
}
With this kind of type :
export type AppStateType = {
username?: string;
isThingOk?: boolean;
arrayOfThing?: Array<MyType>;
...
}
And use it this way :
const appState = AppState.getInstance();
...
...
appState.setAppState({ isThingOk: data });
...
...
appState.state.subscribe((state: AppStateType) => {// do your thing here});
Not sure this is the best way to create a state of your own but it works pretty well. Feel free to adapt it to your needs.
I can recommand you to use some light state management library as zustand: https://github.com/pmndrs/zustand.
You can with this library avoid re-render and specify to re-render just want the data you want change or change in certain way with some function to compare old and new value.

React - Running side effects inside pure functions

Introduction
My current use case requires to store the most fresh state updates in a cache. As state updates are async, and there can be a lot of components updating the same one in parallel, it might be a good option to store them inside the body of the useState or useReducer pure functions.
But... side effects come, and the frustration start. I have tried to await dispatches, creating custom hooks "useReducerWithCallback", and other stuff, but I don't see the correct solution to my problem.
Problem
I have a module usersCache.js which provides me with the necessary methods to make modifications to my cache:
const cache = {};
export const insert = (id, data) => ...
export const get = (id) => ...
// and more stuff
I am trying to update this cache when I make state updates. For example:
const currentUser = useContext(CurrentUserContext);
...
// Note: setData is just the state setter useState hook
currentUser.setData((prevData) => {
const newTotalFollowing = prevData.totalFollowing + 1;
usersCache.update(currentUser.data.id, { newTotalFollowing }); <---- SIDE EFFECT
return { ...prevData, totalFollowing: newTotalFollowing };
});
And same stuff in my otherUsers reducer
import { usersCache } from "../../services/firebase/api/users"
export default (otherUsers, action) => {
switch (action.type) {
case "follow-user": {
const { userId, isFollowing } = action;
const prevUserData = otherUsers.get(userId);
const newTotalFollowers = prevUserData.totalFollowers + (isFollowing ? 1 : -1);
usersCache.update(userId, { totalFollowers: newTotalFollowers }); // merge update
return new Map([
...otherUsers,
[
userId,
{
...prevUserData,
totalFollowers: newTotalFollowers
]
]
);
}
...
}
}
As in pure functions we shouldn't perform side effects... Is there any other approach to handle this?
Note: I am not using Redux
You can check this full working example using the repository pattern and react hooks to simplify async actions with state dispatches. I know you are not using redux but you can adapt this example using the useReducer hook to connect it to your React Context store.

Why is the cleanup function from `useEffect` called on every render?

I've been learning React and I read that the function returned from useEffect is meant to do cleanup and React performs the cleanup when the component unmounts.
So I experimented with it a bit but found in the following example that the function was called every time the component re-renders as opposed to only the time it got unmounted from the DOM, i.e. it console.log("unmount"); every time the component re-renders.
Why is that?
function Something({ setShow }) {
const [array, setArray] = useState([]);
const myRef = useRef(null);
useEffect(() => {
const id = setInterval(() => {
setArray(array.concat("hello"));
}, 3000);
myRef.current = id;
return () => {
console.log("unmount");
clearInterval(myRef.current);
};
}, [array]);
const unmount = () => {
setShow(false);
};
return (
<div>
{array.map((item, index) => {
return (
<p key={index}>
{Array(index + 1)
.fill(item)
.join("")}
</p>
);
})}
<button onClick={() => unmount()}>close</button>
</div>
);
}
function App() {
const [show, setShow] = useState(true);
return show ? <Something setShow={setShow} /> : null;
}
Live example: https://codesandbox.io/s/vigilant-leavitt-z1jd2
React performs the cleanup when the component unmounts.
I'm not sure where you read this but this statement is incorrect. React performs the cleanup when the dependencies to that hook changes and the effect hook needs to run again with new values. This behaviour is intentional to maintain the reactivity of the view to changing data. Going off the official example, let's say an app subscribes to status updates from a friends' profile. Being the great friend you are, you are decide to unfriend them and befriend someone else. Now the app needs to unsubscribe from the previous friend's status updates and listen to updates from your new friend. This is natural and easy to achieve with the way useEffect works.
useEffect(() => {
chatAPI.subscribe(props.friend.id);
return () => chatAPI.unsubscribe(props.friend.id);
}, [ props.friend.id ])
By including the friend id in the dependency list, we can indicate that the hook needs to run only when the friend id changes.
In your example you have specified the array in the dependency list and you are changing the array at a set interval. Every time you change the array, the hook reruns.
You can achieve the correct functionality simply by removing the array from the dependency list and using the callback version of the setState hook. The callback version always operates on the previous version of the state, so there is no need to refresh the hook every time the array changes.
useEffect(() => {
const id = setInterval(() => setArray(array => [ ...array, "hello" ]), 3000);
return () => {
console.log("unmount");
clearInterval(id);
};
}, []);
Some additional feedback would be to use the id directly in clearInterval as the value is closed upon (captured) when you create the cleanup function. There is no need to save it to a ref.
The React docs have an explanation section exactly on this.
In short, the reason is because such design protects against stale data and update bugs.
The useEffect hook in React is designed to handle both the initial render and any subsequent renders (here's more about it).
Effects are controlled via their dependencies, not by the lifecycle of the component that uses them.
Anytime dependencies of an effect change, useEffect will cleanup the previous effect and run the new effect.
Such design is more predictable - each render has its own independent (pure) behavioral effect. This makes sure that the UI always shows the correct data (since the UI in React's mental model is a screenshot of the state for a particular render).
The way we control effects is through their dependencies.
To prevent cleanup from running on every render, we just have to not change the dependencies of the effect.
In your case concretely, the cleanup is happening because array is changing, i.e. Object.is(oldArray, newArray) === false
useEffect(() => {
// ...
}, [array]);
// ^^^^^ you're changing the dependency of the effect
You're causing this change with the following line:
useEffect(() => {
const id = setInterval(() => {
setArray(array.concat("hello")); // <-- changing the array changes the effect dep
}, 3000);
myRef.current = id;
return () => {
clearInterval(myRef.current);
};
}, [array]); // <-- the array is the effect dep
As others have said, the useEffect was depending on the changes of "array" that was specified in the 2nd parameter in the useEffect. So by setting it to empty array, that'd help to trigger useEffect once when the component mounted.
The trick here is to change the previous state of the Array.
setArray((arr) => arr.concat("hello"));
See below:
useEffect(() => {
const id = setInterval(() => {
setArray((arr) => arr.concat("hello"));
}, 3000);
myRef.current = id;
return () => {
console.log("unmount");
clearInterval(myRef.current);
};
}, []);
I forked your CodeSandbox for demonstration:
https://codesandbox.io/s/heuristic-maxwell-gcuf7?file=/src/index.js
Looking at the code I could guess its because of the second param [array]. You are updating it, so it will call a re-render. Try setting an empty array.
Every state update will call a re-render and unmount, and that array is changing.
It seems expected. As per the documentation here, useEffect is called after first render, every update and unmount.
https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects
Tip
If you’re familiar with React class lifecycle methods, you can think
of useEffect Hook as componentDidMount, componentDidUpdate and before
componentWillUnmount combined.
This is a Jest test that shows the render and effect order.
As you can see from the expect, once the dependency foo changes due to the state update it triggers a NEW render followed by the cleanup function of the first render.
it("with useEffect async set state and timeout and cleanup", async () => {
jest.useFakeTimers();
let theRenderCount = 0;
const trackFn = jest.fn((label: string) => { });
function MyComponent() {
const renderCount = theRenderCount;
const [foo, setFoo] = useState("foo");
useEffect(() => {
trackFn(`useEffect ${renderCount}`);
(async () => {
await new Promise<string>((resolve) =>
setTimeout(() => resolve("bar"), 5000)
);
setFoo("bar");
})();
return () => trackFn(`useEffect cleanup ${renderCount}`);
}, [foo]);
++theRenderCount;
trackFn(`render ${renderCount}`);
return <span data-testid="asdf">{foo}</span>;
}
const { unmount } = render(<MyComponent></MyComponent>);
expect(screen.getByTestId("asdf").textContent).toBe("foo");
jest.advanceTimersByTime(4999);
expect(screen.getByTestId("asdf").textContent).toBe("foo");
jest.advanceTimersByTime(1);
await waitFor(() =>
expect(screen.getByTestId("asdf").textContent).toBe("bar")
);
trackFn("before unmount");
unmount();
expect(trackFn.mock.calls).toEqual([
['render 0'],
['useEffect 0'],
['render 1'],
['useEffect cleanup 0'],
['useEffect 1'],
['before unmount'],
['useEffect cleanup 1']
])
});

How to convert a React class component to a function component with hooks to get firebase data

I have a working React class component that I want to convert to a functional component to use hooks for state etc. I am learning React hooks. The class component version works fine, the functional component is where I need help.
The data structure consists of a client list with three "clients". An image of it is here:
All I am trying to do is get this data, iterate over it and display the data of each name key to the user. Simple enough.
The problem is that a call to firebase from my component leads to erratic behavior in that the data is not retrieved correctly. The last client name is continuously called and it freezes up the browser. :)
Here is an image of the result:
Here is the code:
import React, {Component,useContext,useEffect, useState} from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '#material-ui/core/styles';
import Paper from '#material-ui/core/Paper';
import Grid from '#material-ui/core/Grid';
import ListItem from '#material-ui/core/ListItem';
import Button from '#material-ui/core/Button';
import firebase from 'firebase/app';
import {Consumer,Context} from '../../PageComponents/Context';
const styles = theme => ({
root: {
flexGrow: 1,
},
paper: {
padding: theme.spacing.unit * 2,
textAlign: 'center',
color: theme.palette.text.secondary,
},
});
const FetchData = (props) =>{
const [state, setState] = useState(["hi there"]);
const userID = useContext(Context).userID;
useEffect(() => {
let clientsRef = firebase.database().ref('clients');
clientsRef.on('child_added', snapshot => {
const client = snapshot.val();
client.key = snapshot.key;
setState([...state, client])
});
});
//____________________________________________________BEGIN NOTE: I am emulating this code from my class component and trying to integrate it
// this.clientsRef.on('child_added', snapshot => {
// const client = snapshot.val();
// client.key = snapshot.key;
// this.setState({ clients: [...this.state.clients, client]})
// });
//___________________________________________________END NOTE
console.log(state)
return (
<ul>
{
state.map((val,index)=>{
return <a key={index} > <li>{val.name}</li> </a>
})
}
</ul>
)
}
FetchData.propTypes = {
classes: PropTypes.object.isRequired
}
export default withStyles(styles)(FetchData)
By default, useEffect callback is run after every completed render (see docs) and you're setting up a new firebase listener each such invocation. So when the Firebase emits the event each of such listeners receives the data snapshot and each of them adds to the state a received value.
Instead you need to set the listener once after component is mounted, you can do so by providing an empty array of the dependencies ([]) as a second argument to useEffect:
useEffect(() => {
// your code here
}, []) // an empty array as a second argument
This will tell React that this effect doesn't have any dependencies so there is no need to run it more than once.
But there is another one important moment. Since you setup a listener then you need to clean it up when you don't need it anymore. This is done by another callback that you should return in the function that you pass to useEffect:
useEffect(() => {
let clientsRef = firebase.database().ref('clients');
clientsRef.on('child_added', snapshot => {
const client = snapshot.val();
client.key = snapshot.key;
setState([...state, client])
});
return () => clientsRef.off('child_added') // unsubscribe on component unmount
}, []);
Basically this returned cleanup function will be invoked before every new effect is called and right before a component unmounts (see docs) so only this cleanup function should solve your solution by itself, but there's no need to call your effect after every render anyway hence [] as a second argument.
Your problem is that by default, useEffect() will run every single time your component renders. What is happening, is that your effect triggers a change in the component, which will trigger the effect running again and you end up with something approximating an endless loop.
Luckily react gives us some control over when to run the effect hook in the form of an array you can pass in as an additional parameter. In your case for example:
useEffect(() => {
let clientsRef = firebase.database().ref('clients');
clientsRef.on('child_added', snapshot => {
const client = snapshot.val();
client.key = snapshot.key;
setState([...state, client])
});
}, []);//An empty array here means this will run only once.
The array tells react which properties to watch. Whenever one of those properties changes it will run the cleanup function and re-run the effect. If you submit an empty array, then it will only run once (since there are no properties to watch). For example, if you were to add [userId] the effect would run every time the userId variable changes.
Speaking of cleanup function, you are not returning one in your effect hook. I'm not familiar enough with firebase to know if you need to clean anything up when the component is destroyed (like for example remove the 'child_added' event binding). It would be good practice to return a method as the last part of your use effect. The final code would look something like:
useEffect(() => {
let clientsRef = firebase.database().ref('clients');
clientsRef.on('child_added', snapshot => {
const client = snapshot.val();
client.key = snapshot.key;
setState([...state, client])
});
return () => { /* CLEANUP CODE HERE */ };
}, []);//An empty array here means this will run only once.
Effects, by default, run after every render, and setting state causes a render. Any effect that updates state needs to have a dependency array specified, otherwise you'll just have an infinite update-render-update-render loop.
Also, remember to clean up any subscriptions that effects create. Here, you can do that by returning a function which calls .off(...) and removes the listener.
Then, make sure to use the function form of state update, to make sure the next state always relies on the current state, instead of whatever the closure value happened to be when binding the event. Consider using useReducer if your component's state becomes more complex.
const [clients, setClients] = useState([])
useEffect(() => {
const clientsRef = firebase.database().ref("clients")
const handleChildAdded = (snapshot) => {
const client = snapshot.val()
client.key = snapshot.key
setClients(clients => [...clients, client])
}
clientsRef.on("child_added", handleChildAdded)
return () => clientsRef.off('child_added', handleChildAdded)
}, [])
Also see:
How to fetch data with hooks
React Firebase Hooks
A complete guide to useEffect

Categories