A ridiculously lightweight and easy-to-use state management alternative to Redux
If you're a frontend or fullstack developer, chances are you've had some experience working with Redux. If you're anything like me, your initial impression was mostly positive. "What a great tool," you thought, but as time went on you started asking questions like, "but why does it have so much boilerplate? And why does my data disappear on a page reload? Sure I can chuck it in localStorage to persist, but then I'm clogging up memory with unnecessary duplication. And what the heck is a reducer anyway?"
In recent years, many developers have abandoned Redux in favor of the React Context API, which captures nearly all the same use cases with less configuration. But if you're not careful, both of these technologies can lead you into a trap – unnecessary rerenders. At a glance this may seem inconsequential. Maybe you're a pro and you think it's somebody else's problem. But as your application and your team scale in size and complexity, controlling your rerender can become harder and harder to maintain, and may ultimately result in non-trivial performance issues.
- It is aggressively easy to set up, learn, and use
- It reduces your application's boilerplate and runtime memory overhead
- It handles data persistence over page reload without additional configuration or libraries
- Stateful rerender is 100% opt-in, putting you back in charge of your application's performance
- The core library is 100% framework agnostic, and its unpacked bundle size is only 14kb with zero dependencies!!
Instead of overengineering another complex data-persistence solution, no-dux
works by extending the browser's native data-persistence tools through the localStorage
api.
Most developers probably think of localStorage
as a glorified set of key-value pairs. It's fine for storing a few odds and ends, but it's not powerful or flexible enough to meet your application's state-management needs.
no-dux
opens up a new world of possibility by making it easy to set and remove deeply nested data in your browser's localStorage. With no-dux
, you enter your store through a single root node in your localStorage, and from there you can build a data-tree as large and complex as your application. We take care of the nesting and the JSON parsing for you!
Okay, here's the short version:
- use
nodux.createStore
to instantiate your store somewhere near the top level of your application - use
nodux.setItem
to update your store - use
nodux.removeItem
to delete values from your store - use
nodux.getItem
to reference values in your store - keep your code DRY and maintainable by encapsulating your store update methods in reuseable
actions
withnodux.registerActions
// top level module
import { nodux } from '@no-dux/core';
// or '@no-dux/react' when using the react extension
nodux.createStore({ root: 'demoApp' });
// => demoApp = {}
createUserActions();
// optionally instantiate some actions along with your store
const App = () => {...};
// actions creator module
// this pattern functionally replaces the entire constants => actions => reducer boilerplate of Redux
import { nodux } from '@no-dux/core';
const loginUser = (token) => nodux.setItem('auth', { authToken: token });
const logoutUser = () => nodux.removeItem('auth');
const updateUsername = (newName) => nodux.setItem('user', { name: newName });
const updateUiTheme = (darkTheme) => nodux.setItem('uiSettings', { darkTheme });
export const createUseActions = () = {
nodux.registerActions({
loginUser,
logoutUser,
updateUsername,
updateUiTheme,
});
};
// anywhere within your application
import { nodux } from '@no-dux/core';
nodux.setItem('user', { name: 'demoUser', favoriteAnimal: 'Bibo the cat' });
// => demoStore = { user: { name: 'demoUser', favoriteAnimal: 'Bibo the cat' } }
nodux.getItem('user.favoriteAnimal');
// => 'Bibo the cat'
nodux.removeItem('user', ['favoriteAnimal']);
// => demoStore = { user: { name: 'demoUser' } }
// alternatively using normalized actions
const { actions } = nodux;
actions.loginUser(token);
// => demoStore = { auth: { authToken: token } }
actions.updateUiTheme({ darkTheme: true });
// => demoStore = { auth: { authToken: token }, uiSettings: { darkTheme: true } }
npm install @no-dux/core
or
yarn add @no-dux/core
The first thing we'll need to do is instantiate our store somewhere near the top level of our application. If you're working on a React app, you'll likely prefer to use your App
component. We'll also specify some default values so we have some data to work with in upcoming examples. That looks like this:
import { nodux } from '@no-dux/core';
// or @no-dux/react if using the extension
const storeDefaults = {
auth: {
sessionToken: '...',
refreshToken: '...',
...
},
user: {
name: 'dudeGuy',
email: '[email protected]',
id: 'un1qu3$tr1ng',
randomThing: 'moreStuff',
...
},
uiSettings: {
darkTheme: true,
...
},
};
nodux.createStore({ root: 'demoApp', defaults: storeDefaults });
Notice that our example store has a knowledge of some auth data, some user data, and some ui settings.
- The value of
root
will be used as the name for the root node of store's data-tree. If no value is provided, the name will default to "root" - If desired, default values can be set to the store using the
defaultStore
argument. If no defaults are specified, the store will simply be initialized as an empty object at the root node
Suppose our user has a pet whose information we would like to remember. We would get that information from the user and set it in our store like this:
import { nodux } from '@no-dux/core';
nodux.setItem('user.pet', { type: 'cat', name: 'Bibo the cat' });
The path
argument tells no-dux
how to key into the store's data-tree to find the item we'd like to update.
If a node in the path does not exist in the current data-tree, it will be automatically set to an empty object. We don't need to set pet
as a property of user
before we start writing values into it, we can simply write values into user.pet
and no-dux
handles the rest!
We can also use an array to define the path. That looks like this:
nodux.setItem(['user', 'pet'], { type: 'cat', name: 'Bibo the cat' });
The expected result of these operations on our store looks like this:
// store
demoApp: {
auth: {
sessionToken: '...',
refreshToken: '...',
...
},
user: {
name: 'dudeGuy',
email: '[email protected]',
id: 'un1qu3$tr1ng',
randomThing: 'moreStuff',
// pet data updated
pet: {
name: 'Bibo the cat',
type: 'cat',
},
...
},
uiSettings: {
darkTheme: true,
...
},
};
Now, suppose our user decides they want to logout of their current session. We'll probably want to remove any relevant auth information from the store.
To remove an item from our store, we need to provide a path to the item so no-dux
knows where to look for it. The blacklist
argument can be a string, an array of strings, or nothing! Let's explore the possibilities:
nodux.removeItem('auth', ['sessionToken', 'refreshToken']);
The above option will remove only the sessionToken
and refreshToken
properties of the auth object, leaving the rest intact. The result should look like this:
// store
demoApp: {
auth: {
...
},
user: {
name: 'dudeGuy',
email: '[email protected]',
id: 'un1qu3$tr1ng',
randomThing: 'moreStuff',
pet: {
name: 'Bibo the cat',
type: 'cat',
},
...
},
uiSettings: {
darkTheme: true,
...
},
};
But suppose we'd like to remove the auth object entirely. We're just going to reset it when the user logs back in anyway, right?
To remove an item completely, we can simply provide no argument for blacklist
. That looks like this:
nodux.removeItem('auth');
and the expected result of this operation looks like this:
// store
demoApp: {
user: {
name: 'dudeGuy',
email: '[email protected]',
id: 'un1qu3$tr1ng',
randomThing: 'moreStuff',
pet: {
name: 'Bibo the cat',
type: 'cat',
},
...
},
uiSettings: {
darkTheme: true,
...
},
};
See? The auth
object has been completely removed from the store. Anytime no blacklist
argument is provided to removeItem
, nodux will completely remove the last node in the path. Otherwise, it will remove the items specified in the blacklist
. Okay, let's look at one more example of how to use removeItem
:
Suppose we want to remove the randomThing
property from our user
object. Hey what's that random thing doing in there anyway??
Any time we only want to remove a single item, we can specify blacklist
as a simple string, rather than an array of strings, like this:
nodux.removeItem('user', 'randomThing');
Super! Now that we've removed our user's session data and that random thing, our store should look like this:
// store
demoApp: {
user: {
name: 'dudeGuy',
email: '[email protected]',
id: 'un1qu3$tr1ng',
pet: {
name: 'Bibo the cat',
type: 'cat',
},
...
},
uiSettings: {
darkTheme: true,
...
},
};
This one is pretty straightforward. Use nodux.clear when you want to scrap the entire store and return an empty object at your root node. That looks like this:
nodux.clear();
and the result looks like this:
// store
demoApp: {}
IMPORTANT: With no-dux
, stateful update is 100% opt-in. That means that data-getters can provide a snapshot of the store when a component renders, but component rerender will not be triggered by store updates. For stateful component rerender, please see the React Hooks API section of the docs.
We've learned how to set and remove data from our store, but what if we just want to take a peek inside and see what's there? That's where getters come in!
Let's go back to an earlier version of our store example to learn about getters:
// store
demoApp: {
auth: {
sessionToken: '...',
refreshToken: '...',
...
},
user: {
name: 'dudeGuy',
email: '[email protected]',
id: 'un1qu3$tr1ng',
randomThing: 'moreStuff',
pet: {
name: 'Bibo the cat',
type: 'cat',
},
...
},
uiSettings: {
darkTheme: true,
...
},
};
This one is pretty straightforward. It simply returns the entire store! That looks like this:
const store = nodux.getStore();
Easy! Now you have the whole store and you can get whatever you need.
Cool, so getStore is great and all, but you'll likely find it more useful to grab a specific slice of state out of your store. That's where getItem
comes in. It takes in a path just like setItem
and removeItem
, and returns whatever it finds at the end of that path like this:
const worldsBestCat = nodux.getItem('user.pet.name');
console.log(worldsBestCat); // => 'Bibo the cat'
getSize is a convenience method provided to check the current memory usage of your store. It simply calculates memory allocation based on the total number of characters in your JSON-stringified store and displays an alert message on the browser. To use, simply call it in your code like this:
<TestSizeButton onClick={nodux.getSize} />
You can test your store's size however you want. The example above is a TestSizeButton
React component that would reveal the alert message when clicked. Obviously this is intended for testing purposes only and should be removed from your production application.
Super! We're finally ready to talk about actions
!
At this point, many of you may be thinking, "it's true, Redux boilerplate is a pain, but one thing I do like is the way the dispatch => action => reducer
pattern normalizes my actions into reuseable state update methods. I can dispatch the same action from anywhere in my application and get a reliable result. Does no-dux provide a similar solution for action normalization?"
The answer to this question is no-dux actions!
Using actions, you'll have reuseable state-update methods that you can call from anywhere in your application, keeping your code DRY and maintainable.
First you'll want to create a new module where your actions will live. I prefer this convention for structuring my project's file-tree:
demoApp/
src/
components/
bunchOfOtherStuff/
store/
actions/
userActions.js
Now that you've set up your file-tree and created a new module for your actions, you're ready to start defining some normalized actions:
// userActions.js
import { nodux } from '@no-dux/core';
const setUserData = (userData) => nodux.setItem('user', userData);
const setUserPet = (petData) => nodux.setItem('user.pet', petData);
export const createUserActions = () => {
nodux.registerActions({
setUserData,
setUserPet,
});
}
There's no reason you can't do other things in the body of your action too. Perhaps you'd like to build an action that looks more like this:
const setUserData = (payload) => {
const backendData = // do some backend call thing
const updatedPayload = // transform original payload
nodux.setItem('path', updatedPayload);
}
Awesome! The last step to ensure that you have access to your actions throughout your application is to take your handy new createUserActions
function, and call it somewhere near the top level of your application. Right next to createStore
ought to be a good spot:
// top level module
import { nodux } from '@no-dux/core';
import { createUserActions } from './src/store/actions/userActions';
nodux.createStore({ root: 'demoApp' });
createUserActions();
Simple as that! Now our actions will be available through nodux from anywhere in our application.
If you're concerned about the scalability of keeping all of your actions in one large registry, you can add a layer of encapsulation by partitioning your actions into subdomains. Usage is exactly the same as the registerActions
method, except that it takes in a subdomain string as its first argument. The actions provided will now be encapsulated within that subdomain, bringing an extra layer of organization to your action registry.
// userActions.js
import { nodux } from '@no-dux/core';
const setUserData = (userData) => nodux.setItem('user', userData);
const setUserPet = (petData) => nodux.setItem('user.pet', petData);
export const createUserActions = () => {
nodux.registerPartitionedActions('userActions', {
setUserData,
setUserPet,
});
}
Now that you have registered your actions with no-dux and created them at the top of your application, what should you do when you want to use them in a module? Easy, you have access to them through nodux:
// another module anywhere in your application
import { nodux } from '@no-dux/core';
const MyModule = () => {
const userData = fetchUser();
// for un-partitioned actions
const { actions } = nodux;
actions.setUserData(userData);
// for partitioned actions
const { userActions } = nodux.actions;
userActions.setUserData(userData)
// the syntax below is also perfectly fine
// I just prefer the look of the object destructuring way
nodux.actions.setUserData(userData);
};
As our applications grow, we may find ourselves with a ballooning number of actions-creator modules, resulting in a growing list of actions-creator calls in our top level application module:
// top level module
import ...
nodux.createStore({ root: 'demoApp' });
createUserActions();
createProductActions();
createShoppingCartActions();
createFlimflamActions();
createMumboJumboActions();
createRandomActions();
...
If, like me, you don't like the look of a bunch of actions-creators cluttering up your top-level app module, I'd recommend simply adding an index.js
file to your actions directory like this:
demoApp/
src/
components/
bunchOfOtherStuff/
store/
actions/
index.js
...
It might look something like this:
// src/store/actions/index.js
import ... all actions-creators here
export const createStoreActions = () => {
createUserActions();
createProductActions();
createShoppingCartActions();
createFlimflamActions();
createMumboJumboActions();
createRandomActions();
};
And then your app module will be nice and cleaned up:
// top level module
import { nodux } from '@no-dux/core';
import { createStoreActions } from './src/store/actions/index';
nodux.createStore({ root: 'demoApp' });
createStoreActions();
Ahh, now doesn't that feel better?
One of the most exciting features of no-dux
is that it gives you 100% complete control of your component rerenders. That's because all store updates happen the background unless you opt-in to listening for store updates.
That's where the React hooks api comes in. The @no-dux/react
extension allows you to hook into your component state and receive background store updates as local state updates.
Let's remember our store example one more time to talk about the React Hooks API:
// store
demoApp: {
auth: {
sessionToken: '...',
refreshToken: '...',
...
},
user: {
name: 'dudeGuy',
email: '[email protected]',
id: 'un1qu3$tr1ng',
randomThing: 'moreStuff',
pet: {
name: 'Bibo the cat',
type: 'cat',
},
...
},
uiSettings: {
darkTheme: true,
...
},
};
When used without a path
argument, useStore will simply return the entire contents of your store. That looks like this:
// component renders on every store update
const store = useStore();
However, this comes with an obvious downside. When you listen to the whole store, it will update whenever any property at any nested level is updated.
Using the path
argument of the useStore
hook allows you to be more specific about which store updates will trigger component updates:
// component updates only when user updates
const { user } = useStore('user');
nodux.setItem('uiSettings', { darkTheme: false }); // => DOES NOT update component state
nodux.setItem('user', { name: 'guyDude' }); // => updates component state
nodux.setItem('user.pet', { name: 'Bibo the best cat ever' }); // => updates component state
Okay, this is better. Now the store only updates when changes are made to the user
property of our store. Let's take it one step further see just how specific we can get!
// component updates only when user.pet.type changes
const { type: petType } = useStore('user.pet.type');
nodux.setItem('uiSettings', { darkTheme: false }); // => DOES NOT update component state
nodux.setItem('user', { name: 'guyDude' }); // => DOES NOT update component state
nodux.setItem('user.pet', { name: 'Bibo the best cat ever' }); // => DOES NOT update component state
nodux.setItem('user.pet', { type: 'alligator' }); // => only this updates component state
Great! Now we have a super specific state update that targets a leaf-node of our data-tree. You can see that I am aliasing type
as petType
, in case I want to be more descriptive with my naming.
In this example, if the user
property is updated, our component won't rerender because we are targeting more specific property. Even if the user.pet.name
property is updated, we still expect that our component's state will not be updated. Now that's what I call controlling your rerender!
You may be wondering, "what if I want to listen for multiple updates, but maintain my rerender specificity?" This solution is resolved by providing an array of paths instead of a single path. That looks like this:
const { name: userName, darkTheme } = useStore(['user.name', 'uiSettings.darkTheme']);
In this example, component state only updates when the user.name
property or the uiSettings.darkTheme
property changes. As you may have noticed, the returned properties are named using the terminal node of the provided path.
- As with all objects, key names must be unique. Therefore, when providing multiple paths, always ensure that there are no collisions between their terminal nodes.
- When collisions occur it is recommended to simply listen for updates to a different layer of your data-tree
This is a convenience hook provided to make background updates without manually calling setItem
every time. This is especially useful when you remember an array of user preferences that may change regularly with user interactions. Let's look at an example:
Suppose our application has a data-table that enables sorting and pagination, and as a convenience we'd like to remember our user's most recent interaction with the table so that it looks the same every time they return to the page.
We'll start by adding a new tableSettings
object to our user
property:
// store
demoApp: {
auth: {
sessionToken: '...',
refreshToken: '...',
...
},
user: {
name: 'dudeGuy',
email: '[email protected]',
id: 'un1qu3$tr1ng',
randomThing: 'moreStuff',
pet: {
name: 'Bibo the cat',
type: 'cat',
},
// tableSettings added here
tableSettings: {
sortColumn: 'name',
sortDirection: 'desc',
page: 0,
},
...
},
uiSettings: {
darkTheme: true,
...
},
};
Suppose we have a knowledge of the sortOrder
, sortDirection
, and page
values somewhere else in our module, probably stored in a bit of state. Here's how we'll set up useAutosave
to make background updates to our tableSettings
:
useAutosave('user.tableSettings', { sortOrder, sortDirection, page });
And we're done! useAutosave
will listen for changes to the three specified values, and make background updates to the user.tableSettings
slice of state any time these values change.
- Just like with our other methods, the
path
argument can be specified using a string or an array of strings. whitelist
must be specified using an object because we need to know both the value of the variable and the key to assign it to
IMPORTANT: The names of the keys in your whitelist must correspond to the store items you want to write them to. If the naming conventions in your component's local state do not correspond to the target names in your store, you'll likely want to do something like this:
const localSortState = // => locally defined state variable
useAutosave('user.tableSettings', { sortOrder: localSortState, sortDirection, page });
The blacklist
argument is a simple convenience designed to help clean up your code. It can be a string or an array of strings, and it specifies object properties that you would like to omit from the whitelist.
Suppose you have a rather complex object that you would like to track in your store. But you have a problem, there are two pieces of sensitive information inside your complex object that you don't want to track. Without the blacklist, that might look like this:
const { sensitiveThing1, sensitiveThing2, ...rest } = complexObject;
useAutosave('blacklistExample', rest);
Okay this isn't terrible, but supposing you have a scenario need to use multiple object destructuring statements, you can see how the pattern clutters your code as it scales. Here's what it looks like using blacklist:
useAutosave('blacklistExample', {...complexObject1, ...complexObject2 }, ['sensitiveThing1', 'sensitiveThing2', 'sensitiveThing3'])