react-use-sub
Subscription based lightweight React store.
Benefits
- easy to use
- easy testing
- no dependencies
- no react context
- TypeScript support included
- Very small package size (< 1kB gzipped)
- Much better performance than react-redux
- works with SSR
Examples
import { createStore } from 'react-use-sub';
const initialState = { foo: 'bar', num: 2 };
export const [useSub, Store] = createStore(initialState);
import { useSub } from '/path/to/store.js';
export const App = () => {
const { fooLength, num } = useSub(({ foo, num }) => ({ fooLength: foo.length, num }));
const square = useSub(({ num }) => num**2);
return <div>Magic number is: {fooLength * num * square}</div>;
}
import { Store } from '/path/to/store.js';
Store.set({ foo: 'something' });
Store.set(({ foo }) => ({ foo: foo + '_2' }));
expect(Store.get()).toEqual({ foo: 'something_2', num: 2 });
const removeListener = Store.listen(({ foo }) => foo, (nextFoo, prevFoo) => {
if (nextFoo.length > prevFoo.length) {
alert('foo is growing');
}
});
removeListener();
Hints
Let me introduce you to some interesting things.
Optional types
Since version 2.0.0 you
can simply specify the optional type you want.
type State = { lastVisit?: Date };
type State = { lastVisit: null | Date };
Conditional updates
When calling Store.set
with undefined
or a function that returns undefined
has
no effect and performs no update.
Store.set(articles.length ? { stock: articles.length } : undefined);
articles.length && Store.set({ stock: articles.length });
Store.set(({ articles }) => (articles.length ? { stock: articles.length } : undefined));
Store.set(({ articles }) => {
if (articles.length) {
return { stock: articles.length };
}
});
Shallow equality optimization
The returned value of the defined mapper will be compared shallowly against
the next computed value to determine if some rerender is necessary. E.g.
following the example of the App
component above:
Store.set({ foo: '123' });
Store.set({ num: 3 });
Multiple subscriptions in a single component
Please feel free to use multiple subscriptions in a single component.
export const GreatArticle = () => {
const { id, author, title } = useSub(({ article }) => article);
const reviews = useSub(({ reviews }) => reviews);
const [trailer, recommendation] = useSub(({ trailers, recommendations }) => [trailer[id], recommendations[id]]);
return <>...</>;
}
Whenever a store update would trigger any of the above subscriptions the
component will be rerendered only once even if all subscriptions would
return different data. That's a pretty important capability when thinking
about custom hooks that subscribe to certain states.
Multiple store updates
If you perform multiple store updates in the same synchronous task this
has (almost) no negative impact on your performance and leads not to any
unnecessary rerenders. All updates will be enqueued, processed in the next
tick and batched to minimize the necessary rerenders.
Store.set({ foo: 'bar' });
Store.set({ num: 2 });
Store.set({ lastVisit: new Date() });
Multiple stores
You can instantiate as many stores as you like, but make sure you don't create
your own hell with too many convoluted stores to subscribe.
import { createStore } from 'react-use-sub';
export const [useArticleSub, ArticleStore] = createStore(initialArticleState);
export const [useCustomerSub, CustomerStore] = createStore(initialCustomerState);
export const [useEventSub, EventStore] = createStore(initialEventState);
Improve IDE auto-import
If you're exporting useSub
and Store
like mentioned in the
example above, your IDE most likely doesn't suggest importing those
while typing inside some component. To achieve this you could do the
following special trick.
const [useSub, Store] = createStore(initialState);
export { useSub, Store };
Persisting data on the client
Because of the simplicity of this library, there are various ways how to persist data. One
example could be a custom hook persisting into the local storage.
const usePersistArticles = () => {
const articles = useSub(({ articles }) => articles);
useEffect(() => {
localStorage.setItem('articles', JSON.stringify(articles));
}, [articles]);
};
const localStorageArticles = localStorage.getItem('articles');
const initialState = {
articles: localStorageArticles ? JSON.parse(localStorageArticles) : {},
}
const [useSub, Store] = createStore(initialState);
You can also initialize the data lazy inside another effect of the custom hook. You can
use IndexedDB
if you need to store objects that are not lossless serializable to JSON.
You can use sessionStorage
or cookies
depending on your use case. No limitations.
Middlewares
It's totally up to you to write any sorts of middleware. One example of tracking special
state updates:
import { createStore, StoreSet } from 'react-use-sub';
type State = { conversionStep: number };
const initialState: State = { conversionStep: 1 };
const [useSub, _store] = createStore<State>(initialState);
const set: StoreSet<State> = (update) => {
const prevState = _store.get();
_store.set(update);
const state = _store.get();
if (prevState.conversionStep !== state.conversionStep) {
trackConversionStep(state.conversionStep)
}
}
const Store = { ..._store, set, reset: () => _store.set(initialState) };
export { useSub, Store };
Yes, I know, it's basically just a higher order function. But let's keep things simple.
Example: Immer integration
Immer is a package that allows to perform immutable
operations while writing mutable ones. Making it less cumbersome to update deeply nested
data. It is roughly 8x the size of this lib,
but still not extremely large. So you might consider using it to improve readability of your
code. There is no real need for it, but here's an example of how you could achieve an
integration very easily.
import immerProduce from 'immer';
const produce = (fn: (current: State) => void): void =>
_store.set((current) => immerProduce(current, fn));
const Store = { ..._store, produce };
Store.produce((state) => {
state.items.push({ name: 'new' });
})
Testing
You don't need to mock any functions in order to test the integration of
the store. There is "test-util" that will improve your testing experience a lot.
The only thing you need to do is importing it. E.g. by putting it into your "setupTests" file.
import 'react-use-sub/test-util';
Possible downsides: Some optimizations like batched processing of all updates will be disabled.
You won't notice the performance impact in your tests, but you should not relay on the number
of renders caused by the store.
Testing would look like this
export const MyExample: React.FC = () => {
const stock = useSub(({ article: { stock } }) => stock);
return <span>Article stock is: {stock}</span>;
};
describe('<MyExample />', () => {
it('renders the stock', () => {
Store.set({ article: { stock: 1337 } as any });
const { container } = render(<MyExample />);
expect(container.textContent).toBe('Article stock is: 1337');
Store.set({ article: { stock: 444 } as any });
expect(container.textContent).toBe('Article stock is: 444');
});
});
Testing (without "test-util")
You can use the store as is, but you will need "wait" until the update was processed.
The above test would become:
const nextTick = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
describe('<MyExample />', () => {
it('renders the stock', async () => {
Store.set({ article: { stock: 1337 } as any });
const { container } = render(<MyExample />);
expect(container.textContent).toBe('Article stock is: 1337');
Store.set({ article: { stock: 444 } as any });
await nextTick();
expect(container.textContent).toBe('Article stock is: 444');
});
});
SSR
For SSR you want to create a store instance that is provided by a React context. Otherwise, you'll
need to prevent store updates on singletons that live in the server scope and share state with other
requests. To do this you could basically create a StoreProvider
like this one:
import React, { useMemo } from 'react';
import { createStore, StoreType, UseSubType } from 'react-use-sub';
const initialState = { foo: 'bar', num: 2 };
type State = typeof initialState;
const Context = React.createContext<{ useSub: UseSubType<State>; store: StoreType<State> }>({} as any);
export const useStore = (): StoreType<State> => React.useContext(Context).store;
export const useSub: UseSubType<State> = (...args) => React.useContext(Context).useSub(...args);
export const StoreProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const value = useMemo(() => {
const [useSub, store] = createStore(initialState);
return { useSub, store };
}, []);
return <Context.Provider value={value}>{children}</Context.Provider>;
};
You might have already guessed, that this has some caveats, because you would have to get first via
useStore
the store in order to make updates or even provide it down in callbacks to perform these
updates any time later. But this is the complexity price to pay, when handling SSR. We need to make
sure that updates are not performed by the server that cause conflicts with other incoming requests.
The new hooks useStore
and useSub
however are not performing any worse because the React context
value is not updated after the initial render anymore.
Using Server state
You can choose to invoke the store setter only by useEffect
hooks. Then there is no state other than
the same initial state that you're using on server side and nothing need to by synced back to the client.
But, if you really want to call the store setter on server side, then you need to initialize the store on
client side with the state that was added on server side. For this you need to render the state as serializable
JSON into the delivered HTML file and then create the store with that state. Depending on your SSR solution
you can choose various ways to achieve this.
E.g. on server side your code could look like this:
const renderFullPage = (html, preloadedState) => (`
<!doctype html>
<html>
<head>
<title>Redux Universal Example</title>
<script>
window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState)}
</script>
</head>
<body>
<div id="root">${html}</div>
<script src="/static/bundle.js"></script>
</body>
</html>
`
);
And in the code initializing the store you can have this logic:
createStore(typeof window === 'undefined' ? initialState : window.__PRELOADED_STATE__);