The challenges of writing a React library that works well in different environments and scenarios
This topic came to mind when I reviewed my colleagues’ code and realized how challenging creating a robust React library is. Surprisingly, even code found through a Google search, which we often trust to be of high quality, can sometimes cause issues like NextJS’s hydration mismatching error.
Is this library safe? This seemingly straightforward question is surprisingly hard within the React community.
In this article, I will share some tips and best practices for writing a safe React library that is Server-Side Rendering (SSR) safe, concurrent rendering safe, and has optimal dependencies. To illustrate these principles in action, we will review a naive implementation of the useLocalStorage hook.
Disclaimer: While I usually identify myself as a Vue developer, my day job involves maintaining a Next.js app. Please correct me if any information in this article is outdated or incorrect.
What is useLocalStorage?
useLocalStorage is a custom hook that allows you to read from and write to the localStorage in React components. localStorage is a browser API that enables you to store key-value pairs of data in the browser. The hook synchronizes the state of a component with the data stored in localStorage. It’s important to note that, for the sake of brevity in this article, the hook does not refresh the view if changes in localStorage are triggered by other components or browser tabs.
The useLocalStorage hook takes two arguments: a key and an initial value. It returns an array of two values: the stored value and a setter function. You can use the stored value and the setter function to read from and write to localStorage, just like you would with useState.
// A naive custom hook that uses localStorage in React components
function useLocalStorage(key, initialValue) {
// Use useState to store the value in local state
const [storedValue, setStoredValue] = useState(() => {
// Get the value from localStorage
const item = localStorage.getItem(key);
// Parse the value as JSON
return item ? JSON.parse(item) : initialValue;
});
// Use useEffect to update localStorage when the value changes
useEffect(() => {
// Stringify the value as JSON
const valueToStore = JSON.stringify(storedValue);
// Set the value in localStorage
localStorage.setItem(key, valueToStore);
}, [key, storedValue]);
// Return the value and a setter function from the hook
return [storedValue, setStoredValue];
}
The above implementation is intentionally naive. We will review its limitations and improve it accordingly.
What Does SSR Safe Mean?
SSR stands for Server-Side Rendering, a technique for rendering React components on the server and sending the HTML to the browser. SSR can improve the performance, SEO, and accessibility of your app. However, it also requires special considerations for library authors, such as avoiding browser-specific APIs, handling hydration, and supporting streaming.
SSR-unsafe code can lead to Node.js rendering errors on the server or hydration mismatch errors on the client. These errors can cause your app to run slowly, attach event handlers to incorrect elements, or even stop your server from running.
To write an SSR-safe library, you should follow these guidelines:
- Avoid using browser-specific APIs like window, document, or localStorage. These APIs are unavailable on the server and can cause errors or inconsistencies. Instead, use feature detection or fallbacks to handle different environments. For example:
// Allow us to use window server-side
const safeWindow = (typeof window === 'undefined')
? {
addEventListener() {},
removeEventListener() {},
}
: window;
- Avoid rendering different views on the server and the client. Sometimes you may need to render different content on the server and client, as is the case with useLocalStorage. In such situations, you must ensure that the server and client render the same initial content and that the client’s content changes appropriately using useEffect. You can learn more about this in the React documentation on displaying different content on the server and the client.
The naive implementation we reviewed earlier needs to be SSR-safe.
Firstly, it directly uses localStorage without any fallback, which can break server rendering.
Secondly, if we conditionally read from localStorage, it can cause the server and the client to render different content. We need to address these issues to make it SSR-safe.
// SSR safe version
function useLocalStorage(key, initialValue) {
// useState will always return initialValue consistently
const [storedValue, setStoredValue] = useState(initialValue);
// Get the value from localStorage in the effect handler
useEffect(() => {
const item = localStorage.getItem(key);
if (item) {
setStoredValue(JSON.parse(item));
} else {
setStoredValue(initialValue);
}
}, [key, initialValue]);
// Use useEffect to update localStorage when the value changes
useEffect(() => {
// Stringify the value as JSON
const valueToStore = JSON.stringify(storedValue);
// Set the value in localStorage
localStorage.setItem(key, valueToStore);
}, [key, storedValue]);
// Return the value and a setter function from the hook
return [storedValue, setStoredValue];
}
Please note that with the introduction of React Server Components (RSC), ensuring SSR safety becomes even more complex. However, we won’t delve into RSC safety in this article.
What Does Concurrent Rendering Safe Mean?
Concurrent rendering, formerly known as Concurrent Mode, is a new feature in React that enables a better user experience by rendering multiple components simultaneously. However, concurrent rendering also introduces new constraints and potential pitfalls for library authors, such as avoiding side effects, mutable states, and blocking rendering.
To write a Concurrent Rendering safe library, you should follow these guidelines:
- Use functional components and hooks instead of class components and lifecycle methods.
- Use the useEffect hook correctly, ensuring:
1. use the useEffect hook to perform side effects.
2. You clean up any resources in the return function of the hook.
3. You use useLayoutEffect when performing synchronous DOM reading. - Avoid using global or shared mutable states, such as variables, objects, or arrays. Instead, use local state with useState or useReducer hooks, or use context with the useContext hook.
- Do not write to or read from ref.current during rendering, except for initialization. Doing so can lead to unpredictable component behavior.
Now, let’s review the code we discussed earlier and determine whether concurrent rendering is safe.
The original implementation updates localStorage only after the component is updated and the changes are applied to the DOM because the update occurs within a useEffect hook. As a library author, assuming that your users’ code is concurrent rendering safe is not safe.
Specifically, user code may read from localStorage during rendering, leading to inconsistencies between the library code and the user code. To address this, we need to update localStorage before calling setStoredValue.
The second issue is more subtle: we are synchronously reading the value from localStorage, but useEffect does not guarantee synchronous updates. This can result in unexpected content flickering in user code. Since React’s concurrent rendering can delay rendering depending on device performance and rendering time, flickering may or may not be reproduced. As a library author, it is preferable to provide a more robust rendering.
// Concurrent rendering safe version
function useLocalStorage(key, initialValue) {
// useState will always return initialValue consistently
const [storedValue, setStoredValue] = useState(initialValue);
// Get the value from localStorage in the layout effect handler
useLayoutEffect(() => {
const item = localStorage.getItem(key);
if (item) {
setStoredValue(JSON.parse(item));
} else {
setStoredValue(initialValue);
}
}, [key, initialValue]);
// Use useEffect to update localStorage when the value changes
useEffect(() => {
// Stringify the value as JSON
const valueToStore = JSON.stringify(storedValue);
// Set the value in localStorage
localStorage.setItem(key, valueToStore);
}, [key]);
// Use useEffect to update localStorage when the value changes
const setValue = useCallback((value) => {
// Stringify the value as JSON
const valueToStore = JSON.stringify(value);
// Set the value in localStorage
localStorage.setItem(key, valueToStore);
setStoredValue(value);
}, [key, setStoredValue]);
// Return the value and a setter function from the hook
return [storedValue, setValue];
}
Finally, let’s discuss optimal dependencies.
What Are Optimal Dependencies?
In React, hooks have a dependency array that reflects reactive value updates during rendering. However, not all values need to be up-to-date at all times. For example, in the case of useLocalStorage, we don’t want to rerender if initialValue changes. This is because initialValue represents the fallback value to be rendered when the key doesn’t exist in local storage. If the key remains the same, there is no need to rerender the component.
A high-performance library should skip unnecessary view updates by optimizing dependencies. This concept may remind experienced React users of Dan Abramov’s famous blog post on making setInterval declarative with React hooks.
Here’s an optimized version of the useLocalStorage hook that skips the dependency by using a useRef to store the latest value of initialValue:
// Concurrent rendering safe and optimized version
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(initialValue);
const initialValueRef = useRef(initialValue);
useLayoutEffect(() => {
const item = localStorage.getItem(key);
if (item) {
setStoredValue(JSON.parse(item));
} else {
setStoredValue(initialValueRef.current);
}
}, [key]);
useEffect(() => {
const valueToStore = JSON.stringify(storedValue);
localStorage.setItem(key, valueToStore);
}, [key, storedValue]);
const setValue = useCallback((value) => {
const valueToStore = JSON.stringify(value);
localStorage.setItem(key, valueToStore);
setStoredValue(value);
}, [key, setStoredValue]);
return [storedValue, setValue];
}
Conclusion
Writing a safe React library can be challenging but rewarding. By following the tips and best practices discussed in this article, you can create a React library that is SSR-safe, concurrent rendering safe and has optimal dependencies. Remember to consider different environments, avoid browser-specific APIs, handle hydration correctly, and optimize dependencies to provide the best possible experience for your library users.
But that’s not the end of publishing a modern JavaScript library! We have only scratched the surface by addressing React-specific issues. Other critical considerations still need to be tackled, such as browser compatibility, data integrity, and, perhaps, the most frustrating, the module system.
I hope you found this article helpful and informative. Please feel free to leave a comment below and follow me on Medium.
Thank you for reading!
Why Writing a Robust React Library Is Hard was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.