Skip to content

Prototype State

AFib enables you to prototype client-side state at the same time you are prototyping your UI.

Generate state before generating code that depends on it

If you generate your state before you generate code that depends on it, the imports will be added for you.

When AFib generates code that refers to a model or root state object, it looks to see if a file for the class exists in the expected location on the filesystem. If it does, it will automatically import it. If not, the new code will have a compiler error until you manually import the correct file (which you can easily do with a quick-fix in VSCode).

Prototyping state with your UI is Useful

Although in theory you could build prototypes by hard-coding state directly into your UI code, like this:

Widget buildWithSPI(TodoListScreenSPI) {
    final t = spi.theme;
    final rows = t.column();
    rows.add(_buildTodoItem("Remember the milk"));
    rows.add(_buildTodoItem("Mow the grass"));
    ...
}

I find hard-coding prototype data into UI prototypes tedious and inflexible. AFib is designed to allow you to feed test data into your UI prototypes in a flexible way. In doing so, you have the opportunity to prototype client-side data structures at the same time you build prototype UIs.

I find thinking about client-side data structures during prototyping helps me build better UI prototypes.

Designing easy-to-maintain state

This section contains guidelines which are not requirements for using AFib. It explains how to generate immutable state that is convenient to maintain. The AFib generate command in the following section makes it easy to generate state that conforms to these recommendations.

Defer 'linking/joining' of objects as long as possible

Because all AFib state is immutable, you cannot change an existing object by altering its member variables. Instead, you will always create a copy of the object with a new and different (revised) internal state. Given this requirement, it is inconvenient to maintain deeply nested data structures.

For example, suppose you create a TodoItem like this:

@immutable
class TodoItem {
    final String id;
    final String title;
    // The User object referenced below will prove inconvenient, 
    // because anytime a user changes, you have
    // to search through all the todo items and revise any one that refers to
    // that user.  Remember, you can't update the user that is 'pointed to' here
    // by mutating it.  Instead, you make a copy, but this TodoItem will continue
    // to refer to the prior pointer/version unless you explicitly revise it as well
    final User assignedTo;
    ...
}

This design will prove inconvenient, because anytime you update a User, you make a copy of it, and as a result to propogate the update you will have to search through all the TodoItem objects and update each one that refers to that user.

A far more convenient design will be:

@immutable TodoItem {
    final String id;
    final String title;
    final String assignedToId;

    // we will call this from our SPI during UI rendering.  As a result, our 
    // 'linking' of todos to users happens as late as possible.
    User? resolveAssignedTo(UsersRoot users) => users.findById(assignedToId);
}

In this design, the TodoItem does not need to be revised when the assignedTo user changes. We can use the resolveAssignedTo method to look up the assigned to user as we are rendering the UI, ensuring that our UI always shows the latest version of the user.

class TodoListScreenSPI ... {
    ...

    // Here, we are dynamically resolving the user the item is assigned to as the 
    // UI is being rendered.   As a result, if the user changes due to a push notification
    // we will always use the freshest copy.
    User assignedTo(TodoItem todoItem) => todoItem.resolveAssignedTo(context.s.users);
}
Often, you will resolve a referenced object in the SPI, as shown above. The latest user object is stored in the UsersRoot (in context.s.users above), and we look it up in real-time as the UI renders.

Think of State in Terms of Root State and Models they contain

The implication of this 'shallowness' design preference is that your state will usually have only one level of nesting.

The objects immediately below your main XXXState are your root state, and will usually have a map from the String ids of your objects to the objects themselves. For example, the UsersRoot implied above might look like:

@immutable
class UsersRoot {
    final Map<String, User> users;
    ...

    User? findById(String id) {
        return users[id];
    }
}

In this example, User is a model object. Model objects will generally not contain references to other objects (unless you are working with a json-y store like firebase/mongo which has embedded nested data structures, which are all stored under a single id).

Root State and Models in Relational Database Terms

If you are familiar with relational database design, then your root objects will correlate to tables, and your models will correlate to each row in the table.

Your model objects will have member variables containing foreign key values, rather than pointers to other model objects. Rather than 'joining' multiple tables together when you deserialize your objects (the example with the TodoItem containing the User objects directly above), you will wait to 'join' your tables until as late as possible, using the 'resolve' method shown above.

Generating State

AFib's generate state command has options which make generating a state like the one described above easy.

How to Generate Models and Root State

You can generate state using a command like:

dart bin/xxx_afib.dart generate state TodoItem --member-variables "String id; String title;"  --resolve-variables "User assignedTo" --add-standard-root 

This command will create a TodoItem model in the file:

lib/state/models/todo_item.dart

And will also create a root set of TodoItems called TodoItemsRoot in the file:

lib/state/root/todo_items_root.dart
The TodoItemsRoot will automatically be added to your default state view as context.s.todoItems.

If you understand the three flags supplied to this command, you have all the tools you need to conveniently generate state in AFib.

This command generates boilerplate for member and resolve variables

The --member-variables flag creates member variables in your new model, as well as standard revise, copyWith, and simple serialization functionality.

copyWith method

When working with immutable data structures, you will frequently want to change just a few member variables in larger object. You can achieve this with the copyWith method.

This command will generate a standard copyWith method that allows you to create a new object with any of the member variables or resolve variables modified.

Revise methods

If you want, you can sprinkle calls to copyWith throughout your code.

However, I find that relationships often develop between different member variables within an object, and that it is convenient to maintain these relationships within a single method, which I prefix with revise. I also like to be able to see all the available revise methods using typeahead.

By default, AFib will generate revise methods for all the values in --member-variables and --resolve-variables. You can disable this behavior using --no-revise-methods.

These methods are often convenient in their own right, but they are there primarily for consistency. As your app grows more complex, you are likely to add revise... methods which encapsulate more complex update code. Future developers can use the .revise... typeahead to discover standard ways of revising existing models in your app.

Serialization methods

By convention, Dart uses a Map<String, dynamic> to represent nested serial data (e.g. json, data from Firebase Firestore, etc). By default, AFib will include constants and simple seriaization code using this convention.

AFib does not have a serialization framework, and it is not very important that you use this particular form of serialization. However, some AFib-aware libraries (e.g. afib_firestore_firebase) will produce more complete code if you have followed this convention. If you do not want the serialization code, you can use --no-serial-methods.

resolve methods

The --resolve-variables flag above creates a String assignedToId member variable, as well as a resolveAssignedTo method similar to the one described above. It also generates standard revise, copyWith, and serialization functionality.

class TodoItem {
    final String id;
    final String title;
    final String assignedToId;

    ...
    User resolveAssignedTo(UsersRoot users) => users.findById(creatorId);
    ...
}

This command generates and integrates a TodoItemsRoot

The --add-standard-root flag generates a TodoItemsRoot class, and automatically installs it at the root of your state and default state view. As described above, the TodoItemsRoot is just a mapping of String ids to TodoItem objects. It comes with utilities for finding TodoItems and revising the set of items easily. The users root object will be placed in:

lib/state/root/users_root.dart

Client-Side Object Ids should be Strings (even if they aren't)

AFib assumes that your test data will have string-based object identifiers. Using String-based object identifiers for test data makes it easier to debug your code, as the object identifier tells you a lot about the data you are working with in the debugger, and object identifiers often end up embedded in widget ids, making the content of the widget easier to understand in the debugger.

If you are using a json-y backend like Cloud Firestore, String-based object identifiers will be natural. If you are using a relational backend, you are more likely to have integer identifiers. I recommend you convert those to-from Strings during seralization.

If I had specified an integer ID in the command above, like this:

dart bin/xxx_afib.dart generate state TodoItem --member-variables "int id; String title;"  --resolve-variables "User assignedTo" --add-standard-root 

AFib would automatically generate a TodoItem with a String id, and with conversion code to and from integers in the serialization functions, allowing you to continue using String test identifiers on the client side.

Augment Existing State

The boilerplate code created by AFib -- the member variables, constructor parameters, copyWith method, revise and resolve methods, and serialization logic -- is tedious to maintain by hand.

AFib supports a generate augment command, which can add member and resolve variables to an existing model (or route parameter, or query). For example, the following command:

dart bin/xxx_afib.dart generate augment TodoItem --member-variables "String notes;"  --resolve-variables "User creator"

Would add all the boilerplate for both the member variable notes, and the resolve variable creator to an existing TodoItem model.

Generate Root State Directly

It is usually more convenient to generate a root state using the `--add-standard-root flag on the command that generates your model.

However, you can generate a root state object directly by suffixing 'root' to your class name.

dart bin/xxx_afib.dart generate state UsersRoot 

Will generate the file:

lib/state/root/users_root.dart

You can also specify --add-standard-root for a root state object, but when doing so you must specify the model name, like:

dart bin/xxx_afib.dart generate state UsersRoot --add-standard-root User

Note that root objects do not need to use any particular superclass. If the standard map is not appropriate in your case, you can use the --member-variables flag instead, or no flag at all, in which case you can code member variables by hand after the generation command completes.

Additional Root State Code Generation

Generating a new root state object will produce a number of other code modifications:

Adds an accessor method to both your XXXState and XXXDefaultStateView

An accessor is added to your XXXStateModelAccess mixin, which gives access to your new root state object from both XXXState and XXXDefaultStateView. For example, the TodoItem example above would yield a method like:

TodoItemsRoot get todoItems => findType<TodoItemsRoot>();

This method allows you to access your root state object from both queries (described below), and the state view in the SPI by using state.todoItems or stateView.todoItems respectively.

Adds a static initialState method to the new classs

New root state classes have a static initialState method. The initial state of your XXXState is automatically populated with this value when your app first starts. AFib automatically links this initial state into your overall initial application state.

If you specify complex values via the --member-variables flag, AFib may not guess the correct syntax for creating an initial state, yielding compiler errors that you need to fix by hand within the initialState function.

Adds an initial value in your test data, and links it into your xxxStateFullLogin state
By default, AFib will create a XXXTestDataID.stateFullLogin<YourClassName> entry in your test data, also populated with the result of the initialState function, and link it into your overall xxxStateFullLoginState test state. You will most likely want to enhance this test state to be more meaningful, and perhaps add additional test states for your new root object as well.

Adding and Maintaining Test Data

AFib allows you to construct a simple key-value store of test data within the test_data.dart file. Although this store is trivial, it is useful because all of AFib's test types make it easy to refer to the data using its XXXTestDataID... id.

This allows you to share data across tests easily. You saw examples of this above, where instead of constructing a stateView for each distinct UI prototype, I was able to specify XXXTestDataID.xxxStateFullLogin as the state view.

As I mentioned above, the constants within XXXTestDataID are strings.

Defining test data within test_data.dart

By default, AFib will create one subprocedure in test_data.dart for each data type.

void defineTestData(AFDefineTestDataContext context) {
    _defineUsersRoot(context);
    _defineTodoItemsRoot(context);
}

Within that, you can register your own model objects using the define function, and you can add them to one or more root objects.

void _defineUsersRoot(AFDefineTestDataContext context) {
  // define some model objects.
  final userChris = context.define<User>(TDLETestDataID.userChris, User(firstName: "Chris"... ));
  final userKatherine = context.define<User>(TDLETestDataID.userKatherine, User(firstName: "Katherine"...));

  // define some root objects if you like, this is the default that goes into your default
  // 'full login' state.
  context.define(TDLETestDataID.stateFullLoginCountInStateRoot, UsersRoot.fromList([
    userChris,
    userKatherine,
  ]));
}

If you need access to previously registered test data in a subsequent _define... method, you can use the context.find method:

void _defineSomeOtherRoot(AFDefineTestDataContext context) {
  final userChris = context.find<User>(TDLETestDataID.userChris);
  ...
}

There is also a context.findList method, which given a list of test IDs returns a list of the found test objects.

Here again, using resolve variables rather than direct object references makes it easier to maintain the test data, as the order in which test data is constructed is typically less important.