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 } }); };