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 test
npm 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
?async
autoActions
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 Action
redux
's action type
attribute is the name
attribute in key-value-state-container
key-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 />
:
props
props
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