Skip to main content

Local State

"Local state" refers to the presentation logic of single screen or widget. It's usually consists of the following parts:

  1. State class which contains the entirety of the Data the component needs and Actions (functions) that can be performed on it
  2. Hook which returns the State and reacts to its Actions
  3. View which displays the UI based on the current State and triggers the Actions based e.g. on the user input
  4. Coordinator which serves as an entry point for the component by binding the Hook and View together and providing external functionality, like navigation.

State

State should be a simple, immutable class that contains:

  1. Data - all values that are needed to display the component (e.g. the current value of a counter)
  2. Actions - all actions that can be performed on the component by the user (e.g. pressing the button)
  3. Constructor - taking all the data and actions as required named parameters
  4. Derived data - optionally, values that can be easily computed from the other fields of the State (e.g. whether the counter value is valid)
class CounterScreenState {
// Data
final int counterValue;

// Actions
final void Function() onButtonPressed;

// Constructor
const MyScreenState({
required this.counterValue,
required this.onButtonPressed,
});

// Derived data
bool get isCounterValid => counterValue > 0;
}

Best practices

  • By convention the State class should be named <Name>State (e.g. CounterScreenState) where <Name> is the name of the component, making it easier to find.

  • It's helpful to keep Data and Actions separated from each other. However, State can also include complex objects containing both Data and Actions (like FieldState), in which case they can be placed in either section.

  • Actions can take arbitrary parameters (e.g. void Function(int index) onListItemPressed), but it's generally a bad practice to return values from them. Place values needed by the View in the Data fields of the State instead. This also applies to returning a Future when triggering asynchronous operations via Actions. In such cases consider using useSubmitState.

  • While some of the fields of the State may be nullable, all fields should be marked as required in the constructor. The constructor should be called in only one place - at the end of the hook, so there's no scenario where a field should be left uninitialized. Making all fields required prevents this.

  • Since State is a simple immutable class, using a code generator like Freezed may be tempting. However, since State class needs just a constructor and doesn't need operator==, hashCode or copyWith, it's recommended to avoid using generators to keep the complexity low and the code friendly to automated refactors.

Hook

Hook is responsible for creating the State and reacting to its Actions. It can take input values (like screen arguments) and functions allowing it to communicate with external components (like navigation) as parameters. Hook can call any number of other hooks to implement the necessary logic.

CounterScreenState useCounterScreenState({
required CounterScreenArgs args,
required void Function() moveToSuccessScreen,
}) {
final state = useState(args.initialValue);

void onButtonPressed() {
state.value++;
if(state.value == 42) moveToSuccessScreen();
}

return CounterScreenState(
counterValue: state.value,
onButtonPressed: onButtonPressed,
);
}

Best practices

  • By convention Hook should be named use<Name>State (e.g. useCounterScreenState) where <Name> is the name of the component, making it easier to find.

  • Common practice is to define inner functions (like onButtonPressed in the example above) inside the Hook to handle user interactions. This allows to encapsulate more complex logic while still allowing it to access all the hook's state.

View

View is responsible for displaying the UI based on the current state and triggering the actions based e.g. on the user input. Usually it's a StatelessWidget that takes the State as its only parameter, but it can also be StatefulWidget/HookWidget if needed.

class CounterScreenView extends StatelessWidget {
final CounterScreenState state;

const CounterScreenView(this.state);


Widget build(BuildContext context) {
return Scaffold(
//...
body: Center(child: Text("Counter: ${state.value}")),
floatingActionButton: FloatingActionButton(
onPressed: state.onButtonPressed,
//...
),
);
}
}

Best practices

  • By convention View should be named <Name>View (e.g. CounterScreenView) where <Name> is the name of the component, making it easier to find.

  • Common practice is to divide the View into smaller parts by extracting them into separate Widgets. In such cases it's recommended to pass the whole State object to the child Widgets instead of individual fields. This decreases the amount of refactoring needed when fields of the State required by the child Widget change.

Coordinator

Coordinator should be a simple class serving as an entry point for the component. It should bind the Hook and View and provide external functionality (like navigation). It can also be used to pass additional parameters (like route arguments) to the Hook.

class CounterScreen extends HookWidget {
const CounterScreen();


Widget build(BuildContext context) {
final state = useCounterScreenState(
args: ModalRoute.of(context)!.settings.arguments as CounterScreenArgs,
moveToSuccessScreen: () => Navigator.of(context).push(/* ... */),
);

return CounterScreenView(state);
}
}
tip

A HookCoordinator Widget can also be used which is a shorthand for binding a Hook and View together:

class CounterScreen extends StatelessWidget {
const CounterScreen();


Widget build(BuildContext context) =>
HookCoordinator(use: () => useMyScreenState(/* ... */), builder: MyScreenView.new);
}

Note that HookCoordinator is a HookWidget itself so the Coordinator can be a regular StatelessWidget.

File organization

The recommended way to organize all the parts of the local state is to place them in a dedicated directory and them split between State and View:

screens/counter
|- counter_screen.dart - Coordinator and other definitions needed to use the component (like route arguments)
|- state
| |- counter_screen_state.dart - State and Hook
|- view
| |- counter_screen_view.dart - View

Best practices

  • Usually it's best to keep State and Hook in the same file to minimize context switching when working on them. However, if either of them becomes too large, they can be split into separate <name>_state.dart and use_<name>_state.dart files.

  • If the State/View grows and needs to be extracted into separate Hooks/Widgets, they should also be placed in the state/view directories respectively.

See also