Skip to content

Task based user interfaces (using code only ASP.NET Components and MVU)

Maurice CGP Peters edited this page Nov 18, 2021 · 5 revisions

Radix encourages a strict separation between command-based user interface components, to let users execute tasks, and read-only user interface components (queries/read models). These components can be combined in a composite user interface containing combinations of the two.

Read-only components receive notifications of events from an event store and update their state in (near) real-time. Speaking in terms of event sourcing, these components are read models/projection-based. These components set an initial state when first loading up. This initial state can come from any external source, for instance, a backend read model. While these components are loaded, they will only receive updates from the event store that can be applied to the local state.

Radix contains a library (aptly named Radix.Components, being in lack of inspiration) that simplifies creating task-based components for ASP.NET applications, using ASP.NET components. This is where libraries like Blazor heavily depend on. It is built on top of the core library for building event-sourced components and heavily depends on its concepts. The sample application contained in this repo is called "Radix.Inventory" (...) shows how it is intended to be used. The inventory example is a rip-off from the canonical sample app built by Gregg Young (found here), built as a Blazor Server app (but it works in WebAssembly just as well). I will use it to guide the explanation on how to use the library.

TaskBasedComponents can be combined with any other component type, such as plain Blazor components. TaskBasedComponents should be used only to dispatch commands to complete one specific task. For all other purposes, use any other component type that is applicable.

The ELM architecture and Model View Update (MVU)

The library is inspired by the ELM architecture. The lazy programmer I am, I prefer the type system and the compiler to do all the heavy lifting to ensure my code is correct. The ELM architecture helps with this by creating a strongly typed user interface, that, in addition, is very easy to test. Markup-based user interfaces, like Razor pages, are notoriously hard to test. Since functions are used to generate the HTML markup nodes (like elements and attributes), writing a test can be as easy as string comparisons.

Let look at an example View (from the IndexComponent of the sample app). Focus on the markup generation functions and the inline comments for now. This example generates a table containing a list of inventory items.

        protected override Node View(IndexViewModel currentViewModel)
        {
            Node[] inventoryItemNodes = GetInventoryItemNodes(currentViewModel.InventoryItems);

            // concat creates a Concat object, which is a Node consisting of a list of nodes
            return concat(
                // components are nodes as well. This creates a NavLink component provided by ASP.NET. All out-of-the-box and custom components are supported
                navLinkMatchAll(new[] { @class("btn btn-primary"), href("Add") }, text("Add")),
                // h1 is an example of node with the type Element. It takes attributes (also node types) and child nodes as arguments. In this case it has no attributes.
                h1(None, text("All items")),
                // generate a table using a helper function to create the table row nodes
                table(None, inventoryItemNodes)
            );
        }

         private static Node[] GetInventoryItemNodes(IEnumerable<InventoryItemModel>? inventoryItems) =>
        inventoryItems?
            .Select(inventoryItem =>
                tr
                (
                    td
                    (
                        navLinkMatchAll
                        (
                            new[]
                            {
                                href($"/Details/{inventoryItem.id}")
                            },
                            text
                            (
                                inventoryItem.name ?? string.Empty
                            )
                        )
                    ),
                    // conditional output
                    inventoryItem.activated
                    ?
                        td
                        (
                            navLinkMatchAll
                            (
                                new[]
                                {
                                    href($"/Deactivate/{inventoryItem.id}")
                                },
                                text
                                (
                                    "Deactivate"
                                )
                            )
                        )
                    :
                        td
                        (
                            navLinkMatchAll
                            (
                                new[]
                                {
                                    href($"/Activate/{inventoryItem.id}")
                                },
                                text
                                (
                                    "Activate"
                                )
                            )
                        )
                    )
            ).ToArray()
        ??
            Array.Empty<Node>();

Another example shows how responding to events works. It is an excerpt from the AddInventoryComponent that shows how a button click event is handled and how a command is dispatched. If the command is handled successfully, it navigates back to the IndexComponent, otherwise, it shows a (Bootstrap) toast with validation errors.

         button(
                new[]
                {
                    @class("btn btn-primary"), 
                      // handle the onclick event
                      on.click(
                        async args =>
                        {
                            // create and validate a CreateInventoryCommand
                            Validated<InventoryItemCommand> validCommand = CreateInventoryItem.Create(
                                currentViewModel.InventoryItemId,
                                currentViewModel.InventoryItemName,
                                true,
                                currentViewModel.InventoryItemCount);
                            
                            Aggregate<InventoryItemCommand, InventoryItemEvent> inventoryItem = BoundedContext.Create(InventoryItem.Decide, InventoryItem.Update);
                            Option<Error[]> result = await Dispatch(inventoryItem, validCommand);
                            switch (result)
                            {
                                case Some<Error[]>(_):
                                    if (JSRuntime is not null)
                                    {
                                        await JSRuntime.InvokeAsync<string>("toast", Array.Empty<object>());
                                    }

                                    break;
                                case None<Error[]> _:
                                    NavigationManager.NavigateTo("/");
                                    break;
                            }
                        })
                },
                text("Ok")
            )

The View function

Each TaskBasedComponent implements the View function, already shown in the example before. The function is where the user interface markup is generated, based on the latest state of a ViewModel (the M in MVU), which is its sole parameter. Here is the generic signature from the TaskBasedComponent base class:

protected abstract Node View(TViewModel currentViewModel);

The TViewModel is constrained (at the class level) to be a subtype of ViewModel. This is called automatically at any time a (re)render of the view has to take place, typically after an update of the ViewModel.

The Update function

This is the place where you update the state of the ViewModel. It is a virtual method that one would override if handling command does not result in navigating away from the View, and the state must be updated before rendering the effect of the command. This is its signature and default implementation:

protected virtual Update<TViewModel, TEvent> Update { get; } = (state, events) => state;

The Update<TViewModel, TEvent> takes a TViewModel (effectively a read model) and a TEvent. To update the state, apply the event in any way it makes sense for this component so that it represents the correct new state. After the Update function is called, and the state has actually changed (preventing unneeded rendering), it will rerender the component, calling the View function with the new ViewModel state.

Clone this wiki locally