Actions

Actions are values that determine external events and user interactions that cause the Model to change, as expressed in business logic language.

Actions are values

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.

Type-safe 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:

  • Actions are simple value types. It is easy to add serialization and other inspection mechanisms.
  • We can use function overloading to distinguish different types of actions.
  • When pattern matching the combined action type the compiler will complain if we fail to cover some cases.
  • It works well when composing components hierarchically. We will discuss this in the Modularity section.

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.

Alternative schemes

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.

Stringly typed actions

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.

Function actions

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:

  • We can not properly define equality of functions.
  • The arguments of the action, once captured, can not be inspected.
  • They can not be serialized.