Security News
Bun 1.2 Released with 90% Node.js Compatibility and Built-in S3 Object Support
Bun 1.2 enhances its JavaScript runtime with 90% Node.js compatibility, built-in S3 and Postgres support, HTML Imports, and faster, cloud-first performance.
Ridiculously simple state stores with performant retrieval anywhere in your React tree using the wonderful concept of React hooks!
<InjectStoreState>
component for those who don't like change šOriginally inspired by the now seemingly abandoned library - bey. Although substantially different now- with Server-side rendering and Async Actions built in! Bey was in turn inspired by react-copy-write.
Try out a quick example:
yarn add pullstate
After installing, lets define a store by passing an initial state to new Store()
:
import { Store } from "pullstate";
export const UIStore = new Store({
theme: {
mode: EThemeMode.DARK,
},
message: `What a lovely day`,
});
Then, in React, we can start using the state of that store using a simple hook useStoreState()
:
import { UIStore } from "./stores/UIStore";
import { useStoreState } from "pullstate";
const App = () => {
const theme = useStoreState(UIStore, s => s.theme);
return (
<div className={`app ${theme}`}>
<button
onClick={() => {
UIStore.update(s => {
s.theme.mode = theme.mode === EThemeMode.DARK ? EThemeMode.LIGHT : EThemeMode.DARK;
});
}}
>
Switch it up!
</button>
</div>
);
};
Notice, that we also made use of update()
, which allows us to update our stores' state anywhere we please - (literally anywhere in your JS code, not only inside React components - but if you are using Server Rendering, see below š) - over here we simply did it inside a click event to change the theme.
Also notice, the second argument to useStoreState()
:
const theme = useStoreState(UIStore, s => s.theme);
This selects a sub-state within our store. This ensures that this specific "hook" into our store will only update when that specific return value is actually changed in our store. This enhances our app's performance by ignoring any changes in the store this component does not care about, preventing unnecessary renders.
E.g If we had to update the value of message
in the UIStore
, nothing would happen here since we are only listening
for changes on theme
.
If you want you can leave out the second argument altogether:
const storeState = useStoreState(UIStore);
This will return the entire store's state - and listen to all changes on the store - so it is generally not recommended.
To listen to more parts of the state within a store simply pick out more values:
const { theme, message } = useStoreState(UIStore, s => ({ theme: s.theme, message: s.message }));
Lastly, lets look at how we update our stores:
UIStore.update(s => {
s.theme.mode = theme.mode === EThemeMode.DARK ? EThemeMode.LIGHT : EThemeMode.DARK;
});
Using the power of immer, we update a store by calling a function called update()
on it. The argument is the updater function, which is given the current state of our store to mutate however we like! For more information on how this works, go check out immer. Its great.
And that's pretty much it!
As an added convenience (and for those who still enjoy using components directly for accessing these things), you can also work with state using the <InjectStoreState>
component, like so:
import { InjectStoreState } from "pullstate";
// ... somewhere in your JSX :
<InjectStoreState store={UIStore} on={s => s.message}>{message => <h2>{message}</h2>}</InjectStoreState>
The above will sort you out nicely if you are simply running a client-rendered app. But Server Rendering is a little more involved (although not much).
createPullstateCore()
import { UIStore } from "./stores/UIStore";
import { UserStore } from "./stores/UserStore";
import { createPullstateCore } from "pullstate";
export const PullstateCore = createPullstateCore({
UIStore,
UserStore,
});
You pass in the stores you created before. This creates a centralized object from which Pullstate can instantiate your state before each render.
import { PullstateCore } from "./state/PullstateCore";
import ReactDOMServer from "react-dom/server";
import { PullstateProvider } from "pullstate";
// A server request
async function someRequest(req) {
const instance = PullstateCore.instantiate({ ssr: true });
const user = await UserApi.getUser(id);
instance.stores.UserStore.update(userStore => {
userStore.userName = user.name;
});
const reactHtml = ReactDOMServer.renderToString(
<PullstateProvider instance={instance}>
<App />
</PullstateProvider>
);
const body = `
<script>window.__PULLSTATE__ = '${JSON.stringify(instance.getPullstateSnapshot())}'</script>
${reactHtml}`;
// do something with the generated html and send response
}
instantiate()
- passing in ssr: true
stores
property of the instantiated object.update()
directly on the UserStore
here - this is a convenience method (which is actually available on all stores).<PullstateProvider>
to do so, providing the instance
.getPullstateSnapshot()
on the instance and set it on window.__PULLSTATE__
, to be parsed and hydrated on the client.const hydrateSnapshot = JSON.parse(window.__PULLSTATE__);
const instance = PullstateCore.instantiate({ ssr: false, hydrateSnapshot });
ReactDOM.render(
<PullstateProvider instance={instance}>
<App />
</PullstateProvider>,
document.getElementById("react-mount")
);
hydrateSnapshot
and ssr: false
, which will instantiate our new stores with the state where our server left off.So now that we have our stores properly injected into our react app through <PullstateProvider>
, we need to actually make use of them correctly. Because we are server rendering, we can't use the singleton-type stores we made before - we need to target these injected store instances directly.
For that we need a new hook - useStores()
.
This hook uses React's context to obtain the current render's stores, given to us by <PullstateProvider>
.
Lets refactor the previous client-side-only example to work with Server Rendering:
import { useStoreState, useStores } from "pullstate";
const App = () => {
const { UIStore, UserStore } = useStores();
const theme = useStoreState(UIStore, s => s.theme);
const userName = useStoreState(UserStore, s => s.userName)
return (
<div className={`app ${theme}`}>
<button
onClick={() => {
UIStore.update(s => {
s.theme.mode = theme.mode === EThemeMode.DARK ? EThemeMode.LIGHT : EThemeMode.DARK;
});
}}
>
Switch it up, {userName}!
</button>
</div>
);
};
Basically, all you need to do is replace the import
import { UIStore } from "./stores/UIStore";
with the context hook:
const { UIStore } = useStores();
As a TypeScript convenience, there is a method on your created PullstateCore
object of all your stores also named useStores()
which will give you all the typing goodness since it knows about the structure of your stores:
const { UIStore, UserStore } = PullstateCore.useStores();
On the client side, when instantiating your stores, you are now instantiating with your "origin" stores by passing the ssr: false
like so:
const hydrateSnapshot = JSON.parse(window.__PULLSTATE__);
const instance = PullstateCore.instantiate({ ssr: false, hydrateSnapshot });
ReactDOM.render(
<PullstateProvider instance={instance}>
<App />
</PullstateProvider>,
document.getElementById("react-mount")
);
Basically, what this does is re-uses the exact stores that you originally created.
This allows us to directly update those original stores on the client and we will receive updates as usual. On the server, calling instantiate({ ssr: true })
creates a fresh copy of your stores (which is required because each client request needs to maintain its own state), but on the client code - its perfectly fine to directly update your created stores because the state is contained to that client alone.
For example, you could now do something like this:
import { UIStore, GraphStore } from "./stores"
async function refreshGraphData() {
UIStore.update(s => { s.refreshing = true; });
const newData = await GraphDataApi.getNewData();
GraphStore.update(s => { s.data = newData; });
UIStore.update(s => { s.refreshing = false; });
}
ā Caution Though - While this allows for much more ease of use for playing around with state on the client, you must make sure that these state updates are strictly client-side only updates - as they will not apply on the server and you will get unexpected results. Think of these updates as updates that will run after the page has already loaded completely for the user (UI responses, dynamic data loading, loading screen popups etc.).
š© For a tighter-controlled, server-compatible and overall easier experience dealing with these types of async updates, see the next section!
Jump straight into an example here:
More often than not, our stores do not exist in purely synchronous states. We often need to perform actions asynchronously, such as pulling data from an API.
It would be nice to have an easy way to keep our view up to date with the state of these actions without putting too much onus on our stores directly which quickly floods them with variables such as userLoading
, updatingUserInfo
, userLoadError
etc - which we then have to make sure we're handling for each unique situation - it just gets messy quickly.
Having our views naturally listen for and initiate asynchronous state gets rid of a lot of boilerplate which we would usually need to write in componentDidMount()
or the useEffect()
hook.
There are also times where we are server-rendering and we would like to resolve our app's asynchronous state before rendering to the user. And again, without having to run something manually (and deal with all the edge cases manually too) like we saw in the server rendering section above:
const user = await UserApi.getUser(id);
instance.stores.UserStore.update(userStore => {
userStore.userName = user.name;
});
:point_up: And that's without dealing with errors and the like...
Pullstate provides a much easier way to do this through Async Actions.
If you are creating a client-only app, you can create an async action like so:
import { createAsyncAction } from "pullstate";
import { UserStore } from "./stores/UserStore";
const GetUserAction = createAsyncAction(async ({ userId }) => {
const user = await UserApi.getUser(userId);
UserStore.update(s => {
s.user = user;
});
return successResult();
});
If you are using server rendering, create them like this (using your PullstateCore
object with all your stores):
const GetUserAction = PullstateCore.createAsyncAction(async ({ userId }, { UserStore }) => {
const user = await UserApi.getUser(userId);
UserStore.update(s => {
s.user = user;
});
return successResult();
});
Let's look closer at the actual async function which is passed in to createAsyncAction()
:
The first argument to this function is important in that it not only represents the variables passed into your action for each asynchronous scenario, but these arguments create a unique "fingerprint" within this action whenever it is called which helps Pullstate keep track of the state of different executions of this action.
In that sense, these actions should be somewhat "pure" per the arguments you pass in - in this case, we pass an object containing userId
and we expect to return exactly that user from the API.
:warning: Pulling userId
from somewhere else, such as directly within your store, will cause different actions for different user ids to be seen as the same! (because their "fingerprints" are the same) This will cause caching issues - so always have your actions defined with as many arguments which identify that single action as possible! (But no more than that - be as specific as possible while being as brief as possible)
The function should return a certain structured result. Pullstate provides convenience methods for this, depending on whether you want to return an error or a success.
// The structure of the "result" object returned by your hooks
{
error: boolean;
message: string;
tags: string[];
payload: any;
}
Convenience function for success (will set { error: false }
on the result object) e.g:
// successResult(payload = null, tags = [], message = "")
return successResult(somePayload);
Convenience function for error (will set { error: true }
on the result object) e.g:
// errorResult(tags = [], message = "")
return errorResult(["NO_USER_FOUND"], "No user found in database by that name");
The tags
property here is a way to easily react to more specific error states in your UI. The default error result, when you haven't caught the errors yourself, will return with a single tag: ["UNKNOWN_ERROR"]
. If you return an error with errorResult()
, the tag "RETURNED_ERROR"
will automatically be added to tags.
The Pullstate Way :tm:, is keeping your state in your stores as much as possible - hence we don't actually return the new user object, but update our UserStore
along the way in the action (this also means that during a single asynchronous action, we can actually have our app update and react multiple times).
Notice that the async function looks slightly different when using server-side rendering:
<PullstateProvider>
, which will be these.There are five ways to use Async Actions in your code:
For the sake of being complete in our examples, all possible return states are shown - in real application usage, you might only use a subset of these values.
const [started, finished, result, updating] = GetUserAction.useWatch({ userId });
started
here), and then all its states after.true
):
started
: This action has begun its execution.finished
: This action has finishedupdating
: This is a special action state which can be instigated through run()
, which we will see further down.result
is the structured result object you return from your action (see above in action creation).const [finished, result, updating] = GetUserAction.useBeckon({ userId });
Exactly the same as useWatch()
above, except this time we instigate this action when this hook is first called.
Same action states, except for started
since we are starting this action by default
const result = await GetUserAction.run({ userId });
useWatch()
will have started = true
at this moment.There are options (currently only one) which you can pass into this function too:
const result = await GetUserAction.run({ userId }, { treatAsUpdate: true });
As seen in the hooks for useWatch()
and useBeckon()
, there is an extra return value called updating
which will be set to true
under certain conditions:
The action is run()
with treatAsUpdate: true
passed as an option.
The action has previously completed
If these conditions are met, then finished
shall remain true
, but updating
will now be true
as well. This allows the edge case of updating your UI to show that updates to the already loaded data are incoming.
Generally, the return value is unimportant here, and run()
is mostly used for initiating watched actions, or initiating updates.
GetUserAction.clearCache({ userId });
Clears all known state about this action (specific to the passed arguments).
Any action that is still busy resolving will have its results ignored.
Any watched actions ( useWatch()
) will return to their neutral state (i.e. started = false
)
Any beckoned actions (useBeckon()
) will have their actions re-instigated anew.
GetUserAction.clearAllCache();
This is the same as clearCache()
, except it will clear the cache for every single argument combination (the "fingerprints" we spoke of above) that this action has seen.
Any action that is making use of useBeckon()
in the current render tree can have its state resolved on the server before rendering to the client. This allows us to generate dynamic pages on the fly!
The asynchronous action code needs to be able to resolve on both the server and client - so make sure that your data-fetching functions are "isomorphic" or "universal" in nature. Examples of such functionality are the Apollo Client or Wildcard API.
Until there is a better way to crawl through your react tree, the current way to resolve async state on the server-side while rendering your React app is to simply render it multiple times. This allows Pullstate to register which async actions are required to resolve before we do our final render for the client.
Using the instance
which we create from our PullstateCore
object of all our stores:
const instance = PullstateCore.instantiate({ ssr: true });
// (1)
const app = (
<PullstateProvider instance={instance}>
<App />
</PullstateProvider>
)
let reactHtml = ReactDOMServer.renderToString(app);
// (2)
while (instance.hasAsyncStateToResolve()) {
await instance.resolveAsyncState();
reactHtml = ReactDOMServer.renderToString(app);
}
// (3)
const snapshot = instance.getPullstateSnapshot();
const body = `
<script>window.__PULLSTATE__ = '${JSON.stringify(snapshot)}'</script>
${reactHtml}`;
As marked with numbers in the code:
Place your app into a variable for ease of use. After which, we do our initial rendering as usual - this will register the initial async actions which need to be resolved onto our Pullstate instance
.
We enter into a while()
loop using instance.hasAsyncStateToResolve()
, which will return true
unless there is no async state in our React tree to resolve. Inside this loop we immediately resolve all async state with instance.resolveAsyncState()
before rendering again. This renders our React tree until all state is deeply resolved.
Once there is no more async state to resolve, we can pull out the snapshot of our Pullstate instance - and we stuff that into our HTML to be hydrated on the client.
If you wish to have the regular behaviour of useBeckon()
but you don't actually want the server to resolve this asynchronous state (you're happy for it to load on the client-side only). You can pass in an option to useBeckon()
:
const [finished, result, updating] = GetUserAction.useBeckon({ userId }, { ssr: false });
Passing in ssr: false
will cause this action to be ignored in the server asynchronous state resolve cycle.
FAQs
Simple state stores using immer and React hooks
The npm package pullstate receives a total of 18,255 weekly downloads. As such, pullstate popularity was classified as popular.
We found that pullstate demonstrated a not healthy version release cadence and project activity because the last version was released a year ago.Ā It has 1 open source maintainer collaborating on the project.
Did you know?
Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.
Security News
Bun 1.2 enhances its JavaScript runtime with 90% Node.js compatibility, built-in S3 and Postgres support, HTML Imports, and faster, cloud-first performance.
Security News
Biden's executive order pushes for AI-driven cybersecurity, software supply chain transparency, and stronger protections for federal and open source systems.
Security News
Fluent Assertions is facing backlash after dropping the Apache license for a commercial model, leaving users blindsided and questioning contributor rights.