Actions are values that determine external events and user interactions that cause the Model to change, as expressed in business logic language.
It is important to note that actions are values. They are a declarative description of what may happen, not the happening in itself. In the Model section we discuss value-semantic design in detail. All those concerns also apply to the design of actions.
There are many ways you can define actions. Normally, in an application there are different kinds of actions. Consider a typical CRUD application, like the canonical Todo List example. To let the type system help us deal with the different actions, we may define the actions as different types whose instances carry all the information needed to perform the operation:
struct add_todo { std::string content; };
struct remove_todo { std::size_t index; };
struct toggle_todo { std::size_t index; };
These actions may act on a Model like this:
struct todo
{
immer::box<std::string> content;
bool completed = false;
};
struct todos_model
{
immer::flex_vector<todo> todos;
};
Note
We use here the containers types from the Immer library of immutable data-structures instead of those of the standard library. These are discussed in the performance section.
We can now implement a reducer for each of the operations as
overloads of an update_todos()
function:
todos_model update_todos(todos_model m, add_todo a)
{
m.todos = std::move(m.todos).push_back({a.content, false});
return m;
}
todos_model update_todos(todos_model m, remove_todo a)
{
m.todos = std::move(m.todos).erase(a.index);
return m;
}
todos_model update_todos(todos_model m, toggle_todo a)
{
m.todos = std::move(m.todos).update(a.index, [] (auto t) {
t.completed = !t.completed;
return t;
});
return m;
}
Once we have this family of actions and their corresponding reducers,
we can use std::variant and std::visit to combine them into one
single type and function, that we can use when building the
lager::store
:
using todo_action = std::variant<
add_action,
remove_action,
toggle_action
>;
todos_model update(todos_model m, todos_action a)
{
return std::visit([&] (auto a) { return update_todos(m, a); }, a);
}
This approach of using std::variant
to combine strongly typed
actions has multiple advantages:
Tip
You do not need to write one separate reducer function per
action type, like we did in this section. In the
Architecture section we showed how to use
lager::visitor
to pattern match the action
variant using lambdas. This lowers
the amount of boiler-plate required for small reducers.
There are other libraries like Scelta, Atria or
Boost.Hof that are convenient when dealing with variants.
While type-safe action is the preferred way of defining actions, and the one used most often in this document, it is important to note that you can freely define actions however you want, and there are situations where other alternative designs might be better.
Instead of using types and variants, you could use enum
and
switch
/case
to identify the different kinds of actions. You
still need to somehow access the different kinds of arguments to the
actions, for which you may need to resort to union
or mechanism,
which is unsafe while bringing no additional advantages.
In Redux, because of JavaScript, they often use instead stringly typed actions. This is rarely advantageous in C++, but there are situations where you may want to do so, for instance, when implementing a command line or configurable shortcuts. When doing so, it is still useful to have a type safe core set of actions, and to implement the stringly typed ones in terms of them. For example, we can extend the todo actions defined above by adding a string-based action type and a corresponding reducer:
struct todos_command
{
std::string command;
std::string argument;
};
todos_model update(todos_model m, todos_command c)
{
static const auto command_actions =
std::map<std::string, std::function<todos_action(std::string)>>{
"add", [] (auto arg) { return add_todo{arg}; },
"remove", [] (auto arg) { return remove_todo{std::stoi(arg)}; },
"toggle", [] (auto arg) { return toggle_todo{std::stoi(arg)}; },
};
auto it = command_actions.find(c.command);
if (it == command_actions.end())
return m;
else
return update(m, it->second(c.argument));
}
This can also be considered an alternative way of implementing an
intent()
function, as suggested in the Architecture
section.
Some people consider that separating action types and reducers is a form of boiler plate. As such, they are tempted to combine the two. For example, the todos actions and reducer could be rewriten as:
using todos_action = std::function<todos_model(todos_model)>;
todos_action add_todo(std::string content)
{
return [=] (auto m) {
m.todos = std::move(m.todos).push_back({a.content, false});
return m;
};
};
todos_action remove_todo(std::size_t index)
{
return [=] (auto m) {
m.todos = std::move(m.todos).erase(index);
return m;
};
}
todos_model toggle_todo(std::size_t index)
{
return [=] (auto m) {
m.todos = std::move(m.todos).update(a.index, [] (auto t) {
t.completed = !t.completed;
return t;
});
return m;
};
}
todos_model update(todos_model model, todos_action action)
{
return action(model);
}
This approach is, in general, not recommended. While functions that do not capture references are, in fact, values, they are so only in a rather weak sense. They are opaque, imposing several limitations: