A simple, library-agnostic key-value based state container following MVC and redux (uni-directional data flow) patterns.
M - model: the stateV - view: (the "glue" to the) UI (currently React only via key-value-state-container-react and useSelector hook)C - controller - the reducer (and optional autoActions extension function, see the "Todo MVC application" example code below)flux-like (reactive component-oriented)lodash)key-value container)async function, which would save you from writing thunks and middlewares, placing all important code and logic in one place (but you can use synchronous reducers as synchronous reducers as well, see examples)byPassReducer action attribute)autoActions optional functionautoState function for readonly and calculated attributes (like lookup hashes) The example presents the MC parts of the MVC pattern. The V part is missing for now, however, the requirement is V to be reactive (which is understood as refreshing itself as a result to some kind of "signal", executed after one or more state attributes changed). key-value-state-container-react package implements useSelector hook, which is a good example of V part implementation.
Hopefully, if you know redux and react-redux, you will find the example below quite familiar.
"todo""in-progress""done""archived""todo", "in-progress", "done" statuses)"archived" status)"todo" (at the top)"in-progress""done" (at the bottom)M - the model (state)type TaskStatus = "archived" | "done" | "in-progress" | "todo";
type Task = {
emoji?: string;
/**
* Unique task id
*/
id: string;
name: string;
status: TaskStatus;
};
type Filter = {
taskName?: string;
status?: Omit<TaskStatus, "archived">;
};
type State = {
/**
* Current filter applied to the Working Tasks Screen
* making the `workingTasks` list.
* If `undefined` then no filter is applied
*/
filter?: Filter;
/**
* Lookup storing all tasks
* The "source of truth" for tasks
* This object is not bound to UI directly
*/
sourceTasks: Record<string, Task>;
/**
* List that is displayed to the user
* and can be filtered or discarded at any time
*/
workingTasks: Task[];
};
C - the controller (reducer)type Action =
| { name: "add-task"; payload: Task }
/**
* Never delete tasks in system, archive them.
*/
| {
name: "archive-task";
payload: Task["id"];
}
| {
name: "set-filter";
payload?: Filter;
}
/**
* Special action that is dispatched by `autoActions`
* optional function, and not by user/UI or the test suite.
*/
| {
name: "sort-working-tasks";
}
| { name: "update-task"; payload: Partial<Task> & Pick<Task, "id"> };
/**
* Auxiliary structure
*/
const taskSortOrder: Record<TaskStatus, number> = {
archived: -1,
todo: 0,
"in-progress": 1,
done: 2,
};
const getNewTaskId = (state: State): string => {
return `${Object.keys(state.sourceTasks).length + 1}`;
};
export const reducer: Reducer<State, Action> = async ({ state, action }) => {
switch (action.name) {
case "add-task": {
const { payload: task } = action;
return {
...state,
allTasks: {
...state.sourceTasks,
[task.id]: task,
},
workingTasks: [...state.workingTasks, task],
};
}
case "archive-task": {
const { payload: taskId } = action;
const task = state.sourceTasks[taskId];
if (!task) {
return state;
}
const sourceTasks: Record<string, Task> = {
...state.sourceTasks,
[taskId]: {
...task,
status: "archived",
},
};
return {
...state,
sourceTasks,
workingTasks: state.workingTasks.filter((el) => el.id !== taskId),
};
}
case "update-task": {
const { payload: addedTask } = action;
const newTask: Task = {
...state.sourceTasks[addedTask.id],
...addedTask,
};
return {
...state,
allTasks: {
...state.sourceTasks,
[addedTask.id]: newTask,
},
workingTasks: state.workingTasks.map((el) => {
if (el.id === addedTask.id) {
return newTask;
}
return el;
}),
};
}
case "set-filter": {
const { payload } = action;
if (!payload) {
return {
...state,
filter: undefined,
workingTasks: Object.values(state.sourceTasks).filter(
(el) => el.status !== "archived"
),
};
}
const { taskName, status } = payload;
const workingTasks = Object.values(state.sourceTasks).filter((task) => {
if (status && task.status !== status) {
return false;
}
if (
taskName &&
(!task.name.includes(taskName) || task.status === "archived")
) {
return false;
}
return true;
});
return {
...state,
workingTasks,
};
}
case "sort-working-tasks": {
const workingTasks = [...state.workingTasks].sort((a, b) => {
const aOrder = taskSortOrder[a.status];
const bOrder = taskSortOrder[b.status];
if (aOrder === bOrder) {
return a.name.localeCompare(b.name);
}
return aOrder - bOrder;
});
return {
...state,
workingTasks,
};
}
default: {
return state;
}
}
};
autoActions function/**
* Special, optional function called after each action finished executing.
*
* Returns a list of actions that are added for later execution
* to the end of action queue.
*
* If there are no actions to be added, then an empty array is returned.
*
* In brief, we want to sort working tasks very, very often.
* Another benefit: a programmer debugging the code would see this
* action, which could help with reasoning about the code.
*/
export const autoActions: AutoActions<State, Action> = ({ action }) => {
switch (action.name) {
case "add-task":
case "archive-task":
case "set-filter":
case "update-task": {
return [{ name: "sort-working-tasks" }];
}
default: {
return [];
}
}
};
Please read jest test file specification to see how sequence of actions are being executed on the list of tasks as below:
const sourceTasks: Record<string, Task> = {
1: {
emoji: "๐ฅ",
id: "1",
name: "Buy potatoes",
status: "archived",
},
2: {
emoji: "๐",
id: "2",
name: "Buy apples",
status: "in-progress",
},
3: {
emoji: "๐",
id: "3",
name: "Buy bananas",
status: "todo",
},
4: {
emoji: "๐",
id: "4",
name: "Buy oranges",
status: "todo",
},
};
See that no "sort-working-tasks" action is dispatched directly by test suite. It is dispatched by autoActions function (optional extension function).
Although the library is already used in the production environment, it is still in the early stage of development ๐งช, so API and some specific naming might change.
clearAllEnqueuedActions()) and/or sagasnpm install key-value-state-container
or
yarn add key-value-state-container
When modifying the container code (e.g. adding extensions, bug fixing etc) that will be utilized by other projects with the same machine/filesystem, follow the following steps:
key-value-state-container as needednpm run testnpm run pack "dependencies": {
"key-value-state-container": "file:~/key-value-state-container-1.0.0.tgz",
}
npm i in the "consuming" projecttgz caching problemOnce created and consumed, atgz file will get cached (somewhere?). So making changes to the code and repeating the steps above will not make the new changes "visible" ๐ข in the consuming project. The problem might be caused by "integrity" attribute in the package-lock.json file (?).
Anyways, there are two alternative strategies to overcome this:
node_modules folder and package-lock.json in the consuming project,package.json of the key-value-state-container project, e.g. "version": "1.0.1", and repeat the steps aboveThe biggest reason people are staring "state-container" type of projects (and why we have so many React state-containers right now) is state management management being the most important part of each non-trivial UI application development, especially the complex ones. Simply put, state-management is the KING ๐, which especially holds true at the component/application maintenance phase. It is more important than UI framework/CSS solution used.
Secondly, writing a state container is a decision at the architectural, not coding level.
"Brew" ๐บ your own is not only the freedom of making "anything possible", but taking responsibility to make the solution easy to develop, understand and maintain.
In the mid of 2017 I had been using redux and react-redux, enjoying it a lot. However, there was way too much boilerplate code to write. This boilerplate problem was eliminated greatly by redux-toolkit, but some limiting factors of the architecture remained e.g synchronous reducers, middleware, "how to send an action from reducer" problem etc. I felt like I need something simpler, that follows the pattern, but is ready for extensions at the same time.
Luckily, state containers are relatively small and easy to write (certainly much easier than writing a 2D/3D game engine).
Thus having said, learning and fun factors were also important motivation to "brewing own".
redux replacement?Definitely not. redux, react-redux and redux-toolkit are great libraries, and it is a good idea to use it if you are happy and familiarized with them. key-value-state-container is a slightly more "lightweight" and "experimental" approach to state management, with quality in mind at the same time.
redux or redux-toolkit?key-value-state-container-react is the only available UI library binding right now)name attribute instead of type)async instead of being synchronous)redux or redux-toolkit?asyncautoActions optional function, making it possible to "dispatch actions from reducer"autoState optional function for recalculated and read-only attributes (kind of createSlice, but simpler - takes as arguments mainly state and the action, returns a new state - please read documentation for more details),immediateState attributebypassReducer attribute in Actionredux's action type attribute is the name attribute in key-value-state-containerkey-value-state-container production ready?Yes, it is used in production environment (as the time of writing this - which is end of 2023 - for about 2 years).
In practice dozens of:
already use key-value-state-container are maintained and successfully used in the production.
Keep in mind from the Open Source community perspective, the library is still in the early stage of development, so API, some specific naming might change.
key-value-state-container, how many state containers there could be?Maybe it is time to demystify the MVC assumption here a little bit. The MVC pattern served only as starting point, inspiration and reference. key-value-state-container is modern, 2020s component-oriented ES6/TypeScript-compliant state-container. In practice, it means that in a more complex screen there can be dozens of state-containers, e.g.:
<Component1 />s, <Component2 />s etc.The big assumption here is that each component from outside looks like any (React) component and exposes props to the world, the state being managed under the hood. This is a big advantage, as it makes the component much more reusable.
On one hand it is possible to dispatch an action (of a known name and payload attributes) to a state container from any place in application.
However, concrete actions shouldn't be a part of the public API of a state container, as it would make the refactoring of state container much more difficult.
There are two ways to interact with state container used in a <Component />:
propsprops are just not enough)The component API object implements some kind of a facade pattern, so it is possible to change the internal implementation of a state container without breaking the API.
Let's assume we have:
<TreeView /> component that exposes some props and an API object.The component might look like this:
<TreeView id="booksByAuthor" />.
The API object interaction might like this:
/**
* App code
*/
// id of a component as a state container id at the same time
<TreeView id="booksByAuthor" />
// somewhere else in the code
const api = createTreeViewApi("booksByAuthor");
api.setFilter({
authors: ["Tolkien", "J.K. Rowling"],
});
api.destroy();
The internal API object implementation is might look like this:
/**
* TreeView component code
*/
import {
getUniqueId,
registerStateChangedCallback,
unregisterStateChangedCallback,
} from "key-value-state-container";
const createTreeViewApi = (containerId: string) => {
const listenerId = getUniqueId();
return {
destroy: () => {
unregisterStateChangedCallback<State, Action>({
containerId,
listenerId,
statePath: ["filter"],
});
},
onFilterChange: (callback: (filter: Filter) => void) => {
registerStateChangedCallback<State, Action>({
containerId,
callback: ({ newState: { filter } }) => {
callback(filter);
},
listenerId,
statePath: ["filter"],
});
},
setFilter: (authors: string[]) => {
const { filter } = getContainer<State>({ containerId });
dispatch({
name: "set-tree-view-filter",
payload: {
containerId,
filter: {
...filter,
authors,
},
},
});
},
};
};
Please note there is an additional onFilterChange callback, that can be used to synchronize state containers if necessary.
const api = createTreeViewApi("booksByAuthor");
api.onFilterChange((filter) => {
dispatchAction<State, Action>({
name: "filter-changed",
payload: {
containerId: "app-id",
filter,
},
})
});
api.destroy();
Remarks:
<TreeView /> component prop callback like this:<TreeView id="booksByAuthor"
onFilterChange={(filter) => {
dispatchAction<State, Action>({
name: "filter-changed",
payload: {
containerId: "app-id",
filter,
},
});
}}
/>
because this API object might be created anywhere in the code (<TreeView /> does not have be "visible" to access this component)
destroy() (or any method with other name but similar purpose) when finished working with an API object, as each registerStateChangedCallback should be matched with unregisterStateChangedCallback, otherwiseasync brought the opportunity to introduce something like an "action queue" (buffer) (see the source code) and lateInvoke option, that is (heavily) used by useSelector implementation of key-value-state-container-react, saving UI from premature refreshes and providing a nice and cheap UI refresh optimizationbypassReducer action attribute), so reducer might just return the current state, butstate in this case)Although this question is very generic and can be applied to any state-container, here are some examples:
The answer to this question is threefold:
<Screen>
<Form>
<Input />
<Input />
<Input />
<Select />
<Input />
</Form>
<Form>
<Select />
<Select />
<PieChart />
</Form>
</Screen>
there might be a single store (state-container) at the <Screen /> level and 4 more stores (state-containers) of the <Select /> and <PieChart />. If these components are delivered by a third party, the only store developer cares about is the <Screen /> store.
There is no silver bullet regarding this, yet here are some guidelines:
key-value-state-container name stay?Yes and not. The name is a little bit too long and not "catchy" enough. However, it is not a priority right now.
There is a possibility as well for a new spin-off project, with a new name, carrying some of the ideas.
The MIT License (MIT)
Copyright Tomasz Szatkowski and WealthArc https://www.wealtharc.com (c) 2023
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Generated using TypeDoc