Guides

Composing Components

This is a quick exploration of composing components within Flutter Forge.


Composition, or the act of putting multiple things together, is something we have to do all the time in software development. Especially when building UIs in Flutter as the widget tree is a hierarchy of composed widgets.

Composition is something that Flutter Forge takes seriously, as we are well aware that being able to compose things up of smaller pieces is critical. The trick though is maintaining all our core tenets while also facilitating composition.

Composing Components

One of the biggest types of composition you do in Flutter apps is composition of widgets. We have the same need when using Flutter Forge. But we have the goal that we always want our widgets to be modular so that they can be extracted, reused, etc.

To facilitate this there is a reality that the parenting widget must fully encapsulate the child components. This means that the parenting widget's state, environment, and actions all have to worry about the concerns of their respective fully encapsulated child components. So, if you have a widget C that is a parent that encapsulates widgets A and B. Widget C's state becomes an amalgamation of A and B's state. The same thing goes for C's environment and actions.

Note: This isn't a side effect of Flutter Forge. It is simply a side effect of writing any modular, extractable code.

With the above in mind we can look at actually composing components. This is done the same way that we would compose a widget up of another child widget. You just use the child component's widget inside of parent component's widget.

Now, remember, all Flutter Forge components take in a store as their formalized interface. So you have to provide the child widget with a store that matches its types, not the parent component's types. Also remember, that the parent component's state, environment, and actions need to be amalgamation of its child component's state, environment, and actions.

Scoping Stores

Flutter Forge provides a number of method on the store to facilitate taking a parent component's store and converting it from the parent component's state, environment, and actions down to a store matching the child component's state, environment, and actions. This concept can be generally thought of as scoping a store down.

The following are the available methods to facilitate scoping a store down.

  • scopeParentHandles() - creates a scoped store such that the child store doesn't directly handle actions. Instead, the child store receives child actions, converts them to parent actions, and then hands them to the parent store. Using this method also sets up conversion and syncing of the parent state to the child state.
  • scopeSyncState() - creates a scoped store that does directly handle actions. This method also sets up bidirectional state conversion and syncing between the parent and the child.
  • scopeForwardActionsAndSyncState() - creates a scoped store that does directly handle actions, but it also converts and forwards the actions to the parent store. This method also sets up bidirectional state conversion and syncing between the parent and the child.

The above methods take a variety of different arguments depending on which scoping mechanism you want to use. Most of these parameters have to do with defining the conversion between the parent and the child's state, environment, and actions. These functions are as follows.

  • toChildState - a function that takes in parent state and is responsible for converting it to the child state
  • fromChildState - a function responsible for taking in current state and child state and converting it to parent state
  • fromChildAction - a function responsible for taking the child action and converting it to the parent action
  • toChildEnvironment - a function responsible for converting from parent environment to child environment

What does this really look like?

Let's take a look at what this might actually look like in practice. If we assume that we are building a component that is going to be composed of a child component which is a simple counter component, it might look as follows.

Note: The counter component knows nothing about being composed. It is modular, isolated, and reusable.

// Environment
class CounterEnvironment {}

// State
@immutable
class CounterState extends Equatable {
  const CounterState({required this.count});

  final int count;

  @override
  List<Object> get props => [count];
}

// Actions
sealed class CounterAction implements ReducerAction {}

class CounterIncrementButtonTapped implements CounterAction {}

// Reducer
final counterReducer = Reducer<CounterState, CounterEnvironment, CounterAction>(
    (CounterState state, CounterAction action) {
  switch (action) {
    case CounterIncrementButtonTapped _:
      return ReducerTuple(CounterState(count: state.count + 1), []);
  }
});

// Widget
class CounterWidget
    extends ComponentWidget<CounterState, CounterEnvironment, CounterAction> {
  const CounterWidget({super.key, required super.store, super.builder});

  @override
  Widget build(context, viewStore) {
    return Column(children: [
      Rebuilder(
          store: store,
          builder: (context, state, child) {
            return Text(
              '${state.count}',
              style: Theme.of(context).textTheme.headlineMedium,
            );
          }),
      OutlinedButton(
          onPressed: () => viewStore.send(CounterIncrementButtonTapped()),
          child: const Text("increment"))
    ]);
  }
}

Now if we look at the parent component that is composed up of an instance of the above counter component. It looks as follows.

// Environment
class ParentEnvironment {}

// State
@immutable
class ParentState extends Equatable {
  const ParentState({required this.counterState});

  final CounterState counterState;

  @override
  List<Object> get props => [counterState];

  ParentState copyWith({CounterState counterState}) {
  	return ParentState(counterState: counterState ?? this.counterState);
  }
}

// Actions
sealed class ParentAction implements ReducerAction {}

class ParentIncrementButtonTapped implements ParentAction {}

// Reducer
final parentReducer =
    Reducer<ParentState, ParentEnvironment, ParentAction>((state, action) {
  switch (action) {
  	case ParentIncrementButtonTapped _:
      return ReducerTuple(
        ParentState(
          counterState:
              CounterState(count: state.counterState.count + 1),
        ),
        [],
	  );
  }
});

// Widget
class ParentWidget
    extends ComponentWidget<ParentState, ParentEnvironment, ParentAction> {
  const ParentWidget({
    super.key,
    required super.store,
	super.builder,
  });

  @override
  Widget build(context, viewStore) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Parent Composed of Child Component'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            CounterWidget(
              store: store.scopeSyncState(
                toChildState: (state) => state.counterState,
                fromChildState: (state, childState) => state.copyWith(counterState: childState),
                toChildEnvironment: (_) => CounterEnvironment(),
                childReducer: counterReducer,
              ),
            ),
            TextButton(
                onPressed: () => viewStore.send(IncrementButtonTapped()),
                child: const Text("parent increment counter"))
          ],
        ),
      ),
    );
  }
}

Analysis

If we start looking at the ParentState.

@immutable
class ParentState extends Equatable {
  const ParentState({required this.counterState});

  final CounterState counterState;

  @override
  List<Object> get props => [counterState];

  ParentState copyWith({CounterState counterState}) {
  	return ParentState(counterState: counterState ?? this.counterState);
  }
}

We can see that it nests the CounterState within it as counterState. Remember the parent state is an amalgamation of its child components states due to modularity's requirement of encapsulation. In the above we can see that we simply added a child property to the ParentState which maps 1-to-1 with the child state. In this case the CounterState. Nesting the state in this fashion makes it easier to implement the mapping logic when scoping the parent store down to a child store. But if you are in a situation where you feel it makes more sense to have more complicated mapping when scoping and a different structure in the parent state. There is no technical reason you can't take that approach.

The next bit of code unique to the composition of the child component is really the actual composition itself.

CounterWidget(
  store: store.scopeSyncState(
	toChildState: (state) => state.counterState,
	fromChildState: (state, childState) => state.copyWith(counterState: childState),
	toChildEnvironment: (_) => CounterEnvironment(),
	childReducer: counterReducer,
  ),
),

In the example above we construct a CounterWidget, and place it in the widget tree as a child of the ParentWidget. This is basic Flutter widget composition that we have to do with all widgets in Flutter.

However, this child widget (CounterWidget) is a Flutter Forge component and therefore requires that we provide it a store that conforms to the CounterState, CounterEnvironment, and CounterAction types.

Given that we are making the parent component modular and extractable, it must encapsulate the state of its children. As we saw in the state above we nested an instance of CounterState inside the ParentState as counterState.

So all we have to do now in apply the concept of scoping a store down which was introduced above. In this particular case I chose to use the scopeSyncState() method to convert the parent store of types ParentState, ParentEnvironment, and ParentAction down to a store of types CounterState, CounterEnvironment, and CounterAction.

Doing this involved providing a number of conversion/mapping functions to scopeSyncState(). Specifically the following.

toChildState

As mentioned above is a function that takes in parent state and is responsible for converting it to the child state. In this particular case because we have the child state nested as counterState this is as simple as a (state) => state.counterState.

fromChildState

As mentioned above is a function responsible for taking in current state and child state and converting it to parent state. In this particular example this is aided by the use of the common copyWith pattern. Specifically, we simply use, (state, childState) => state.copyWith(counterState: childState).

toChildEnvironment

A function responsible for converting from the parent environment to the child environment. In this particular example there is nothing injected via the environment in the child. Therefore, we can simply provide it with a constructed empty one, (_) => CounterEnvironment().

childReducer

This parameter is not about mapping like the others. Instead, it is simply providing the ability to control which reducer is used. In our example we want it to use the provider counterReducer. Therefore, we simply provide it as the argument.

Conclusion

That is it. It is actually pretty simple. The big thing to get used to is really just thinking about the types at the different layers and understanding the converting between them using the methods outlined above for scoping stores.

Previous
Interact with External Systems