State as Communication Channel
It's time to make it possible to add new TodoItem
s to the state. To start off,
extract TodoForm
view out of src/App.tsx
. Create a new file
src/TodoForm.tsx
with following contents:
const TodoForm = () => (
<input
className="new-todo"
placeholder="What needs to be done?"
autoFocus={true}
/>
);
export default TodoForm;
Update src/App.tsx
accordingly:
+ import TodoForm from "./TodoForm";
...
- <input
- className="new-todo"
- placeholder="What needs to be done?"
- autoFocus={true}
- />
+ <TodoForm />
Since global-state is the only kind of state recommended in Engine, a state
variable should be kept for what user is typing in our TodoForm
input. Update
src/TodoForm.tsx
to make its content be:
const TodoForm: view = ({
updateNewTodoTitle = update.newTodo.title,
newTodoTitle = observe.newTodo.title,
}) => (
<input
className="new-todo"
placeholder="What needs to be done?"
autoFocus={true}
value={newTodoTitle || ""}
onChange={(e) => updateNewTodoTitle.set(e.currentTarget.value)}
/>
);
export default TodoForm;
Above snippet:
- Labeled
TodoForm
asview
, so that it can use observe and update in its header - Introduced a new state path
.newTodo.title
- Update
newTodo.title
whenever user enters something in the<input>
A new todo should be added to the todosById
object whenever user presses
Enter
key in the input. It is possible to create an event handler in the view
itself which does this work, but Engine recommends to not do it from the view
.
Only logic that should go into a view is converting event payloads into value
they contain, and store them at some path in state. All the business logic
belongs in producers.
Next steps are to:
- Add event listener for
onKeyDown
in the input - Convert the pressed key to the intent TodoForm want to express, and store it in the state
- Create producers for committing and discarding the new todo
In src/TodoForm.tsx
:
+ import { TodoItem, TodoStatuses, TodoModes } from "./types";
+ enum NewTodoIntents {
+ commit = "commit",
+ discard = "discard"
+}
const TodoForm: view = ({
updateNewTodoTitle = update.newTodo.title,
newTodoTitle = observe.newTodo.title,
+ updateNewTodoIntent = update.newTodo.intent
}) => {
+ const keyDownToIntent = (e) => {
+ if (e.key === "Enter") {
+ updateNewTodoIntent.set(NewTodoIntents.commit);
+ }
+ if (e.key === "Escape") {
+ updateNewTodoIntent.set(NewTodoIntents.discard);
+ }
+ };
return (
<input
className="new-todo"
placeholder="What needs to be done?"
autoFocus={true}
value={newTodoTitle || ""}
onChange={e => updateNewTodoTitle.set(e.currentTarget.value)}
+ onKeyDown={keyDownToIntent}
/>
);
};
view
s can contain as much logic as required to provide a clean API. A view's
API is made up of two things:
- Its input: props and global state
- Its output: JSX and global state
A good API do not reveal its implementation details. State shouldn't need to
know which key is getting pressed, but only what is the objective that a view
want to accomplish. To provide a clean API, an event listener can be created in
the view itself, which stores the intent of the TodoForm component in state in
.newTodo.intent
.
Using state as a communication mechanism between components and producers allows
keeping the views free of all business logic, which is kept in small producers
which do one thing well. addNewTodo
is going to be one such producer. Make
these changes in src/TodoForm.tsx
to create a new producer:
const addNewTodo: producer = ({
newTodoIntent = observe.newTodo.intent,
getTitle = get.newTodo.title,
updateTodosById = update.todosById,
updateNewTodoTitle = update.newTodo.title,
updateNewTodoIntent = update.newTodo.intent,
}) => {
if (newTodoIntent !== NewTodoIntents.commit) {
return;
}
updateNewTodoIntent.remove();
const title = getTitle.value().trim();
if (!title) return;
const id = String(new Date().getTime());
const newTodo: TodoItem = {
id,
title,
status: TodoStatuses.pending,
mode: TodoModes.viewing,
};
updateTodosById.merge({
[id]: newTodo,
});
updateNewTodoTitle.set(null);
};
And add it to the list of TodoForm
's producers:
+ TodoForm.producers([addNewTodo]);
export default TodoForm;
addNewTodo
producer is doing a couple of interesting things:
-
It uses
get.newTodo.title
instead ofobserve.newTodo.title
. get is another macro, which provides a function to get live value from the state. It is very useful when our producer is doing something asynchronous and needs a value from state at a later time. Or as is the case now, it allow accessing a value withoutobserve
ing it.A
producer
orview
gets triggered every time anything itobserve
changes.addNewTodo
producer should not get called whenevernewTodo.title
changes. It is only interested in changes innewTodoIntent
-
Notice that a guard has been added in starting of the producer, which checks if state is valid for execution of this producer. This is a common pattern in Engine apps, since it recommends creating small, single-responsibility producers. The guard checks if the intent of newTodo is to commit it, if it isn't, this producer should not do anything.
In the spirit of single-responsibility producers, another producer can be created to cancel adding a new todo if user presses Escape key.
const cancelAddingTodo: producer = ({
newTodoIntent = observe.newTodo.intent,
updateNewTodoTitle = update.newTodo.title,
updateNewTodoIntent = update.newTodo.intent,
}) => {
if (newTodoIntent !== NewTodoIntents.discard) {
return;
}
updateNewTodoIntent.remove();
updateNewTodoTitle.set(null);
};
Notice it has a guard similar to addNewTodo
.
Adding it to TodoForm.producers
will bring it to life:
- TodoForm.producers([addNewTodo]);
+ TodoForm.producers([addNewTodo, cancelAddingTodo]);
Although new todos are getting added to the state, and "Pending count" in footer
increases on adding new todos, new todos are not shown in the todo list.
visibleTodoIds
in the state need to be kept in sync with changes in
todosById
. It is in charge of which todos are visible in the list. Question
is, where do the producer for updating visibleTodoIds
belong? Should a
producer be added in TodoForm
, which adds the todos, or should it go in App
,
which shows the list of todos?
Engine recommends that views which consume the derived state should track
it. Add a producer in src/App.tsx
:
const syncVisibleTodoIds: producer = ({
todosById = observe.todosById,
filter = observe.filter,
visibleTodoIds = update.visibleTodoIds,
}) => {
const todoIdsToDisplay = Object.entries(todosById as TodosById)
.map(([key, value]) => {
switch (filter as TodoFilters) {
case TodoFilters.completed:
return value.status === TodoStatuses.done ? key : null;
case TodoFilters.pending:
return value.status === TodoStatuses.done ? null : key;
default:
return key;
}
})
.filter(Boolean);
visibleTodoIds.set(todoIdsToDisplay);
};
This view is doing a bit more than just adding all the id
s from todosById
.
It also accounts for existence of a filter
in state, which don't yet exist in
state. This is how Engine help gradually evolving the state as application
evolves. The filter will be set later, when user clicks on "All", "Active" and
"Completed" buttons in the Footer
. But before that, add this producer to
App
:
App.producers([syncVisibleTodoIds]);
export default App;
Before adding filters to state, let's create an enum to represent all the
possible filters. In src/types.tsx
, add:
export enum TodoFilters {
all = "all",
completed = "completed",
pending = "pending",
}
Making a very simply change to src/Footer.tsx
allows setting filters for
visible todos:
- import { TodoItem, TodoStatuses } from "./types";
+ import { TodoItem, TodoStatuses, TodoFilters } from "./types";
const Footer: view = ({
pendingCount = observe.pendingCount,
+ filter = observe.filter,
+ updateFilter = update.filter
}) => (
...
<ul className="filters">
<li>
- <a href="#/" className="selected">All</a>
+ <a
+ href="#/"
+ className={filter === TodoFilters.all ? "selected" : ""}
+ onClick={() => updateFilter.set(TodoFilters.all)}
+ >
+ All
+ </a>
</li>
<li>
- <a href="#/active">Active</a>
+ <a
+ href="#/active"
+ className={filter === TodoFilters.pending ? "selected" : ""}
+ onClick={() => updateFilter.set(TodoFilters.pending)}
+ >
+ Active
+ </a>
</li>
<li>
- <a href="#/completed">Completed</a>
+ <a
+ href="#/completed"
+ className={filter === TodoFilters.completed ? "selected" : ""}
+ onClick={() => updateFilter.set(TodoFilters.completed)}
+ >
+ Completed
+ </a>
</li>
</ul>
It's also possible to set an initial filter by setting it in the initial state.
In src/index.tsx
:
+ import { TodoFilters } from "./types";
...
state: {
initial: {
+ filter: TodoFilters.all,
todosById: {