Best Practice
Destructuring
Some of the React Hooks in foxact
will return an object of memoized values. You can use destructuring (opens in a new tab) to extract the values you need, e.g:
import { useClipboard } from 'foxact/use-clipboard';
export default function App() {
const { copied, copy } = useClipboard();
const handleCopyButtonClick = useCallback(() => copy('Hello World'), [copy]);
return (
<div>
<button onClick={handleCopyButtonClick}>{copied ? 'Copied' : 'Copy Hello World'}</button>
</div>
);
}
Here, the copy
function returned by useClipboard
is memoized, so it's safe to use it as a dependency in the useCallback
hook.
Other more re-usable utilities and hooks from foxact
will return an array of memoized values, so you can give them a more descriptive name by using array destructuring (opens in a new tab), e.g:
import { createContextState } from 'foxact/context-state';
const [TokenProvider, useToken, useSetToken] = createContextState<string | null>(null);
const [SidebarActiveProvider, useSidebarActive, useSetSidebarActive] = createContextState(false);
const Provider = ({ children }: React.PropsWithChildren) => (
<TokenProvider>
<SidebarActiveProvider>
{children}
</SidebarActiveProvider>
</TokenProvider>
);
export {
Provider,
useToken, useSetToken,
useSidebarActive, useSetSidebarActive
};
import { useIntersection } from 'foxact/use-intersection';
const [setAvatarIntersection, isAvatarIntersected, resetAvatarIsIntersected] = useIntersection<HTMLImageElement>();
const [setThumbnailIntersection, isThumbnailIntersected, resetThumbnailIsIntersected] = useIntersection<HTMLImageElement>();
const setToken = useSetToken();
And returned functions (like setAvatarIntersection
, resetAvatarIsIntersected
, setThumbnailIntersection
, resetThumbnailIsIntersected
, and setToken
) are always memoized, so it's safe to add them to any dependencies array.
Why / Why not
Global state management library vs useContext + useState
Global state management libraries (like Redux (opens in a new tab) or Zustand (opens in a new tab)) usually store the state outside of React, then use useSyncExternalStore
to sync the state into the React.
useSyncExternalStore
is a new React built-in hook introduced in React 18. It is a trade-off between React Concurrent Rendering and consistency (without tearing). Currently (React 18.2), when React encounters useSyncExternalStore
during the concurrent rendering, React will "de-optimize" and fall back to the synchronous rendering (slower and less responsive) to make sure there is no inconsistency (tearing). Only states stored inside of React (using useState
and useReducer
) are concurrent-rendering-friendly.
Global state management libraries are indeed very powerful and great for React application that has complex logic and complicated data flow. But most of the time, a simple state shared across the application is good enough.
Why not useIsMounted
It is common to use useIsMounted
to check if the component is mounted before calling setState
during useEffect
:
// ONLY FOR DEMONSTRATION, NEVER DO THIS IN REAL WORD REACT PROJECT
const isMountedRef = useIsMountedRef();
useEffect(() => {
someAsyncStuff().then(data => {
if (isMountedRef.current) {
// trying to avoid set state on unmounted component
setData(data);
// BUT NO, DO NOT TO THIS. THIS IS WRONG
}
});
});
The real problem is that you are trying to avoid is not updating the state after the component has been unmounted, but what you really should do is to avoid updating the state after the current effect has been unsubscribed / cleanup-ed.
Imagine this common race condition:
interface ExampleComponentProps {
dataKey: 'data1' | 'data2'
}
const ExampleComponent = ({ dataKey }: ExampleComponentProps) => {
const [data, setData] = useState(null);
// ONLY FOR DEMONSTRATION, NEVER DO THIS IN REAL WORD REACT PROJECT
const isMountedRef = useIsMountedRef();
useEffect(() => {
someAsyncStuff(dataKey).then(data => {
if (isMountedRef.current) {
// trying to avoid set state on unmounted component
setData(data);
// BUT NO, DO NOT TO THIS. THIS IS WRONG
}
});
}, [dataKey]);
return (
<div>{data}</div>
);
};
│ Request data 1 ────────────────────────────────────────► data1 response (setData(data1)) │
│ Request data 2 ────► data2 response (setData(data2)) │
Here, although the request for data1
happened before data2
, the response for data2
is received before data1
. And useIsMountedRef
doesn't help with that.
To properly avoid setData(data1)
from being called, the correct pattern is described below.
interface ExampleComponentProps {
dataKey: 'data1' | 'data2'
}
const ExampleComponent = ({ dataKey }: ExampleComponentProps) => {
const [data, setData] = useState(null);
useEffect(() => {
let isCancelled = false;
someAsyncStuff().then(data => {
if (!isCancelled) {
setData(data);
}
});
return () => {
isCancelled = true;
};
}, [dataKey]);
}
│ Request data 1 ───────────────────────────────────────────────────────► data1 response │
| isCancelled: false | isCancelled: true | isCancelled: true, no setData(data1)
│ Request data 2 ────► data2 response (setData(data2)) │
| isCancelled: false | isCancelled: false, setData(data2)
You can also use useAbortableEffect
to achieve the same thing with less boilerplate code:
interface ExampleComponentProps {
dataKey: 'data1' | 'data2'
}
const ExampleComponent = ({ dataKey }: ExampleComponentProps) => {
const [data, setData] = useState(null);
// Do notice that useAbortableEffect requires AbortController support
useAbortableEffect((signal) => {
someAsyncStuff().then(data => {
if (signal.aborted) return
setData(data);
});
}, [dataKey]);
}
│ Request data 1 ───────────────────────────────────────────────────────► data1 response │
| aborted: false | aborted | aborted, no setData(data1)
│ Request data 2 ────► data2 response (setData(data2)) │
| aborted: false | aborted: false, setData(data2)