How to build a library in React

Introduction
I built a library that adds selectors to React's context: React Context Selector.
This was a follow up to my blog post where I dive into problems with Context. I concluded that they're just missing selectors. Otherwise, it's all good.
In this post, I quickly wanna go over how to build a library in React.
Introduction to my library
A quick intro on how you'd use it:
// 1. Create a context
const [CounterContext, Provider] = createSelectContext({
count: 0,
name: "Tiger",
});
// 2. Set up the provider
function App() {
return (
<Provider>
<Counter />
</Provider>
);
}
// 3. Use the context with a selector inside a component
const count = useContextSelector(CounterContext, (state) => state.count);
// 4. Update the state inside a component
const setState = useContextSetState(CounterContext);
// Only components consuming "count" will re-render
setState((state) => ({ ...state, count: state.count + 1 }));
You can read the readme for more details and how to configure things. It supports custom comparators and an option to opt into debug mode.
Tsup
I used tsup to build the library. Tsup is a way to bundle your library with no config. Now, I had a config because I wanted to set "react" as an external. Since it's a peerDependency. In hingsight, I didn't need it. According to the docs:
By default tsup bundles all import-ed modules but dependencies and peerDependencies in your package.json are always excluded, you can also use --external <module|pkgJson> flag to mark other packages or other special package.json's dependencies and peerDependencies as external.
Because React is a peerDependency, see here, having a tsup.config.ts wasn't necessary.
Anyways, you can read up on that if you want.
What's exciting is a hook React exposes called useSyncExternalStore. This hook is used to subscribe to external stores. Think of it as React's way of keeping an eye on data that lives outside your React app. You notify it through a callback and it makes sure your UI updates whenever that external data changes.
Introduction to useSyncExternalStore
const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
getServerSnapshot is optional. It's used to get the initial state on the server. I won't focus too much on it here.
Let's take a look at a quick example using a simple pub/sub pattern:
// First let's make a super simple store
const createStore = (initialValue) => {
let value = initialValue;
let listeners = new Set();
return {
// Get current value
get: () => value,
// Update value and notify subscribers
set: (newValue) => {
value = newValue;
listeners.forEach((listener) => listener());
},
// Subscribe to changes (returns unsubscribe function)
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
};
// Create a store with initial count of 0
const countStore = createStore(0);
// Now here's how you use it in a component:
function Counter() {
const count = useSyncExternalStore(
countStore.subscribe, // How to subscribe to changes
countStore.get // How to get the current value
);
return (
<div>
Count: {count}
<button onClick={() => countStore.set(count + 1)}>Increment</button>
</div>
);
}
Here, we're not using any React state (not needed, you'll see why). Don't do this by the way, it's overkill for this counter example. I'm showing this simple example to show how it works.
We create a simple store with subscribers (the listeners Set). We use a Set to avoid duplicates. If the callback is a new reference, it's ok to be added.
When store value changes via set(), we notify all subscribers. Calling
listener()will re-render the component (that's why we don't need React state 😄).useSyncExternalStore takes two things:
How to subscribe (our subscribe function that adds to listeners)
How to get current value (our get function)
Whenever store changes, React re-renders components using the value returned from
getSnapshot(yes, you can add "pure" logic here, no side effects!).
Under the hood, pseudo code might look like this:
function useSyncExternalStore(subscribe, getSnapshot) {
// React creates an internal state to track the store value
const [state, setState] = useState(getSnapshot());
// The listener React sets up internally
function ourListener() {
// Get latest value from store
const nextState = getSnapshot();
// If value changed, trigger a re-render
if (nextState !== state) {
setState(nextState);
}
}
// Set up subscription when component mounts
useEffect(() => {
// Subscribe and get cleanup function
const cleanup = subscribe(ourListener);
// Run listener once to get initial state
ourListener();
// Clean up subscription when component unmounts
return cleanup;
}, []);
// Return current snapshot of the store
return state;
}
Now, useSyncExternalStore is integrated into React. So the code above is far different from the actual implementation. It's just to give you an idea of how it works.
Above, I mentioned how you can add logic to getSnapshot. It's the same as logic you can add during render. It needs to be pure. You shouldn't do side effects there.
Why keep render logic pure?
React docs has a good explanation:
When render is kept pure, React can understand how to prioritize which updates are most important for the user to see first. This is made possible because of render purity: since components don’t have side effects in render, React can pause rendering components that aren’t as important to update, and only come back to them later when it’s needed.
Concretely, this means that rendering logic can be run multiple times in a way that allows React to give your user a pleasant user experience. However, if your component has an untracked side effect – like modifying the value of a global variable during render – when React runs your rendering code again, your side effects will be triggered in a way that won’t match what you want. This often leads to unexpected bugs that can degrade how your users experience your app. You can see an example of this in the Keeping Components Pure page.
Before we dive into any code, I think it's worth highlighting again that the components will only re-render when the listener is called (which you can do anywhere in your store, if you e.g. got other methods).
High level architecture
[IMAGE PLACEHOLDER]
Why don't I also just show you the code. It's gonna be easier for me to explain.
Code for createSelectContext:
export function createSelectContext<State>(initialState: State) {
const store: Store<State> = {
state: initialState,
listeners: new Set(),
setState(fn) {
const nextState = fn(store.state);
store.state = nextState;
store.listeners.forEach((listener) => listener());
},
subscribe(listener) {
store.listeners.add(listener);
// Return cleanup function
return () => store.listeners.delete(listener);
},
getSnapshot() {
return store.state;
},
};
const Context = createContext<Store<State> | null>(null);
function Provider({ children }: { children: React.ReactNode }) {
return <Context.Provider value={store}>{children}</Context.Provider>;
}
return [Context, Provider] as const;
}
Code for useContextSelector:
export function useContextSelector<State, Selected>(
Context: React.Context<Store<State> | null>,
selector: (state: State) => Selected,
options: Options<Selected> = {}
): Selected {
const store = useContext(Context);
const compare = options.compare ?? Object.is;
if (!store) {
throw new Error("Context Provider is missing");
}
const previousSelectedStateRef = useRef<Selected | null>(null);
const selectedState = useSyncExternalStore(
store.subscribe,
() => {
const nextSelectedState = selector(store.getSnapshot());
if (previousSelectedStateRef.current !== null) {
const shouldUpdateState = !compare(
previousSelectedStateRef.current,
nextSelectedState
);
if (shouldUpdateState) {
previousSelectedStateRef.current = nextSelectedState;
return nextSelectedState;
}
return previousSelectedStateRef.current;
}
// First time render
previousSelectedStateRef.current = nextSelectedState;
return nextSelectedState;
},
// Server snapshot
// No need to compare here
() => selector(store.getSnapshot())
);
return selectedState;
}
Code for useContextSetState:
export function useContextSetState<State>(
Context: React.Context<Store<State> | null>
): SetStateFn<State> {
const store = useContext(Context);
if (!store) {
throw new Error("Context Provider is missing");
}
return store.setState;
}
How it works
createSelectContext
createSelectContext is a function that creates a context and a provider. Here's the key, we're not using any state. So by default, there is nothing in there that will trigger a re-render. We only use Context here as a way to pass down the store via useContextSelector hook.
This works per component basis, every component that uses the useContextSelector hook will become a subscriber to the store.
Whenever something in the store changes, we call store.listeners.forEach((listener) => listener()). This will trigger the "sync" logic to happen. Before useSyncExternalStore decides to re-render a component, it makes sure it's not the same as the previous snapshot.
If it is, it won't re-render. There is no reason to re-render if the state is the same.
useContextSelector
We already dove into this. Let'd dive deeper.
We use previousSelectedStateRef to keep track of the previous state. It's only needed because we want to support custom comparators. We know we can only do the custom comparator logic if we have both previous and next state.
If you want to understand how it works in React when you keep track of previous state via a ref, read: Implement and understand usePrevious hook.
If you pass a custom comparator, we check if your logic is true or not. By default, we use Object.is which is a shallow comparison. If it is not true, meaning shouldUpdateState is false, it means the state is NOT the same. Therefore, we update the previous state and return the next state (from getSnapshot).
Another thing I've not touched upon is the selector function.
When you pass a selector function, you're taking something from an object. It's important to note that every time you update this via setState, you're updating the entire object by spreading in the old state. Yes, this create a new object every time. However, it's not a problem. Because inside the state, the only thing that's "new" is the field you're updating.
As you saw in the beginning, we use useContextSetState to get the setState function.
To make it a bit clear why "updating the entire object" is not a problem that causes re-renders every time:
const setState = useContextSetState(CounterContext);
setState((state) => ({ ...state, count: state.count + 1 }));
Let's say we had a todos field here. We're spreading it in but updating count.
Why would it not re-render?
Because the array is the same. It's the same reference. The only "new" reference is the full object around this. Arrays under the hood are objects. Objects inside objects are not "really" inside. Every object has fields that point to other objects in memory.
Read this if it's hard to understand: Mental Model of JavaScript.
Publishing to npm
I used pnpm to publish my library to npm. It's super simple.
- Make sure you have an npm account and are logged in:
pnpm login
- Create a package.json if you don't have one:
pnpm init
- Verify your package.json has:
Unique name (
namefield)Version (
versionfield)Entry point (
mainfield)Files to include in the package (
filesfield)Public access (
publishConfigfield)
Set up exports in package.json (the modern way), including the recommended fallbacks:
{
"name": "@yourscope/package-name",
"version": "1.0.0",
"main": "./dist/index.cjs", // Fallback for older environments
"module": "./dist/index.js", // Fallback for older ESM
"types": "./dist/index.d.ts", // Fallback for TypeScript
"files": ["dist"], // Files to include in the package
"publishConfig": {
"access": "public" // Public access (open source packages)
},
"exports": {
".": {
"types": "./dist/index.d.ts", // TypeScript types
"import": "./dist/index.js", // ESM environments
"require": "./dist/index.cjs", // CommonJS environments
"default": "./dist/index.js" // Fallback for edge cases
}
}
}
Why we need fallbacks:
Old Node.js versions don't support
exportsSome bundlers might not fully support
exportsTools might fall back to
main/module/typesfieldsThe
defaultin exports helps catch any other cases
- Build your project:
pnpm build
- Publish:
pnpm publish
For following updates:
Update your code
Update version (
pnpm version patch/minor/major, this automatically updates the package.json for you)pnpm publish
Common gotchas to check:
Include a
.npmignoreorfilesin package.json to control what gets publishedMake sure you're not in a Git directory with uncommitted changes
Set proper
main,module, andtypesfields for TypeScript packages
Conclusion
I hope this helps you understand or at least get you started.






