diff --git a/src/apps/todo-state/App.jsx b/src/apps/todo-state/App.jsx
index 0656b82..d89840f 100644
--- a/src/apps/todo-state/App.jsx
+++ b/src/apps/todo-state/App.jsx
@@ -1,23 +1,41 @@
/* eslint no-param-reassign: 0 */
import React from 'react';
import TodoList from '../../components/TodoList';
-import initialState from '../../initial_state';
-import { todoState } from './state/todos';
+import { StateProvider, useStore } from './state/index';
+import initTodos from './state/todos';
-const TodoState = () => {
- const [, actions] = todoState(initialState);
+initTodos();
+
+const TodoListWrapper = ({ children }) => {
+ const { state, commit } = useStore('todos');
+ const { todos } = state;
const onChange = action => {
const { todo, completed, name } = action.payload;
- if (action.type === 'completed') actions.setCompleted(todo, completed);
- if (action.type === 'rename') actions.setName(todo, name);
- if (action.type === 'remove') actions.remove(todo);
+ const todoMatch = todos.find(t => t.id === todo.id);
+ if (todoMatch < 0) return;
+
+ const { id: todoId } = todoMatch;
+
+ if (action.type === 'completed') commit('markCompleted', todoId, completed);
+ if (action.type === 'rename' && name) commit('rename', todoId, name);
+ if (action.type === 'remove') commit('remove', todoId);
};
+ // janky prop injection, just to reduce boilerplate here
+ return React.cloneElement(children, { onChange, todos });
+};
+
+const TodoState = () => {
return (
-
State Demo
-
+
+ State Demo
+ {/* TodoListWrapper provides todos and onChange props to TodoList */}
+
+
+
+
);
};
diff --git a/src/apps/todo-state/state/index.js b/src/apps/todo-state/state/index.js
new file mode 100644
index 0000000..6280b99
--- /dev/null
+++ b/src/apps/todo-state/state/index.js
@@ -0,0 +1,121 @@
+/* eslint no-param-reassign: 0, react/destructuring-assignment: 0, react/no-multi-comp: 0 */
+import React, { createContext, useContext } from 'react';
+import PropTypes from 'prop-types';
+import { produce, original } from 'immer';
+
+// keep track of all the stores
+const stores = new Map();
+
+// USAGE: createStore('myStore', { state, mutations, actions, getters })
+export function createStore(name, storeSpec) {
+ // don't duplicate stores
+ if (stores.has(name)) throw new Error(`Store '${name}' already exists`);
+
+ const spec = {
+ state: {},
+ mutations: {},
+ actions: {},
+ getters: {},
+ ...storeSpec,
+ };
+
+ // create a new context for the store and add it to the map
+ const storeContext = createContext({
+ state: spec.state,
+ commit: () => {},
+ dispatch: () => {},
+ getters: () => {},
+ });
+
+ stores.set(name, {
+ context: storeContext,
+ provider: class StoreProvider extends React.Component {
+ static propTypes = {
+ children: PropTypes.node.isRequired,
+ };
+
+ state = {
+ state: spec.state,
+ commit: (n, ...args) => {
+ const mutation = spec.mutations[n];
+ if (!mutation) throw new Error(`No such mutation: ${n}`);
+ this.setState(
+ produce(draft => {
+ mutation({ state: draft.state, original }, ...args);
+ })
+ );
+ },
+ dispatch: async (n, ...args) => {
+ const action = spec.actions[n];
+ if (!action) throw new Error(`No such action: ${n}`);
+ return produce(this.state.state, async draft => {
+ // NOTE: this does not change state, it's expected that
+ // actions use commit to update the state
+ return action({ state: draft, commit: this.state.commit }, ...args);
+ });
+ },
+ get: (n, ...args) => {
+ const getter = spec.getters[n];
+ if (!getter) throw new Error(`No such getter: ${n}`);
+ return getter(this.state.state, ...args);
+ },
+ };
+
+ render() {
+ return React.createElement(
+ storeContext.Provider,
+ { value: this.state },
+ this.props.children
+ );
+ }
+ },
+ // TODO: keep track of the intial state, for resetting
+ // initialState: { ...spec.state },
+ });
+}
+
+// TODO: method to build a larger state object made of stores
+// composeStores({
+// todos: todoStore
+// });
+
+// hook to access a specific store and read/write methods
+// USAGE: const { state, commit } = useStore('myStore')
+export function useStore(name) {
+ if (!stores.has(name)) throw new Error(`Store does not exist: '${name}'`);
+ const store = stores.get(name);
+ return useContext(store.context);
+}
+
+// provider to wrap your app or component
+export class StateProvider extends React.Component {
+ static propTypes = {
+ children: PropTypes.node.isRequired,
+ names: PropTypes.string,
+ };
+
+ static defaultProps = {
+ names: '',
+ };
+
+ render() {
+ const { names, children } = this.props;
+ const namesArray = names.length ? names.split(',') : [];
+
+ // provide all stores if none are specified
+ const provideAllStores = namesArray.length === 0;
+
+ // build an array of all the store providers
+ const providers = provideAllStores
+ ? Array.from(stores).map(([, store]) => store.provider)
+ : namesArray.map(name => {
+ if (!stores.has(name)) throw new Error(`Store does not exist: '${name}'`);
+ return stores.get(name).provider;
+ });
+
+ // nest children inside all of the providers
+ return providers.reduce((acc, provider) => {
+ return React.createElement(provider, {}, acc);
+ }, children);
+ }
+}
diff --git a/src/apps/todo-state/state/todos.js b/src/apps/todo-state/state/todos.js
index dd197d1..2cf5156 100644
--- a/src/apps/todo-state/state/todos.js
+++ b/src/apps/todo-state/state/todos.js
@@ -1,46 +1,59 @@
/* eslint no-param-reassign: 0 */
-import { useEffect } from 'react';
-import { useImmerReducer, original } from '../../../hooks/use_immer';
+import { createStore } from '.';
-let currentState;
-
-export const setState = state => {
- currentState = state;
-};
-
-export function todoState(initialState) {
- if (!currentState && initialState) setState(initialState);
-
- const [todos, dispatch] = useImmerReducer(
- (state, action) => {
- const { todo, completed, name } = action.payload;
- const todoIndex = original(state).findIndex(t => t === todo);
- if (todoIndex < 0) return;
-
- if (action.type === 'setState') state = action.payload;
- if (action.type === 'completed') state[todoIndex].completed = completed;
- if (action.type === 'rename' && name) state[todoIndex].name = name;
- if (action.type === 'remove') state.splice(todoIndex, 1);
+export default () =>
+ createStore('todos', {
+ // initial state
+ state: {
+ todos: [
+ {
+ id: 1,
+ name: 'eat',
+ complete: false,
+ },
+ {
+ id: 2,
+ name: 'sleep',
+ completed: false,
+ },
+ {
+ id: 3,
+ name: 'breath',
+ completed: false,
+ },
+ ],
},
- currentState,
- state => state || []
- );
- useEffect(() => {
- dispatch({ type: 'setState', currentState });
- }, [currentState]);
+ // sync changes
+ mutations: {
+ add({ state }, name) {
+ state.todos.push({
+ name,
+ id: state.todos.length,
+ completed: false,
+ });
+ },
+ remove({ state, original }, id) {
+ state.todos = original(state).todos.filter(t => t.id !== id);
+ },
+ markCompleted({ state, original }, id, completed = true) {
+ const idx = original(state).todos.findIndex(t => t.id === id);
+ state.todos[idx].completed = completed;
+ },
+ rename({ state }, id, name) {
+ state.todos = state.todos.map(t => {
+ if (t.id === id) t.name = name;
+ return t;
+ });
+ },
+ },
- const actions = {
- setCompleted(todo, completed = true) {
- dispatch({ type: 'completed', payload: { todo, completed } });
- },
- setName(todo, name) {
- dispatch({ type: 'rename', payload: { todo, name } });
- },
- remove(todo) {
- dispatch({ type: 'remove', payload: { todo } });
- },
- };
+ // async actions that cal mutations
+ actions: {},
- return [todos, actions];
-}
+ // pull details out of state
+ getters: {
+ completedCount: state => state.todos.filter(t => t.completed).length,
+ outstandingCount: state => state.todos.filter(t => !t.completed).length,
+ },
+ });
diff --git a/src/components/TodoList.jsx b/src/components/TodoList.jsx
index 39675db..3a7a958 100644
--- a/src/components/TodoList.jsx
+++ b/src/components/TodoList.jsx
@@ -1,10 +1,7 @@
import React from 'react';
-import { todoState } from '../apps/todo-state/state/todos';
import TodoItem from './TodoItem';
-const TodoList = ({ onChange }) => {
- const [todos] = todoState();
-
+const TodoList = ({ todos, onChange }) => {
const onComplete = todo => () => {
onChange({ type: 'completed', payload: { todo, completed: !todo.completed } });
};