fst 0.2.0 fst: ^0.2.0 copied to clipboard
Perfect cohabitation of Widget and State trees.
Flutter State Tree #
Perfect cohabitation of Widget and State trees.
Introduction #
State tree is a representation of the application state. It provides an interface to read and update the state. Flutter State tree implements State Tree interface in a way, that allows Flutter Widget and State trees to coexist. State tree could be derived from Widget tree at any given moment, since every State Tree Node is also a Widget.
Each State Tree Node could have multiple leaf nodes (leaf node is a node without children). Leaf nodes could notify their parent node and trigger a rebuild.
Flutter State Tree is focused on simplicity and performance. It allows to update only relevant parts of UI.
There are two ways to create a State Tree Node:
- Use
StateTreeBuilder
. - Create a custom widget that extends
StateTreeWidget
.
Documentation #
StateTreeBuilder
StateTreeWidget
StateTreeNode
- Provider shadowing
StateTreeBuilder
#
StateTreeBuilder
is a widget that creates a StateTreeNode
inside your widget tree.
It takes a builder function of two arguments: BuildContext
and StateTreeNode
.
StateTreeNode
provides everything for querying and updating the state.
class CounterApp extends StatelessWidget {
const CounterApp({super.key});
@override
Widget build(BuildContext context) {
return StateTreeBuilder(
(context, n, child) {
final (value, _, updateValue) = n.state(() => 0);
n.provide(value);
n.provide(updateValue);
return child;
},
child: Scaffold(
body: StateTreeBuilder((context, n, child) {
final value = n.consume<int>();
return Text('$value');
}),
floatingActionButton: StateTreeBuilder((context, n, child) {
final setValue = n.consume<void Function(int Function(int))>();
return FloatingActionButton(
onPressed: () => setValue((v) => v + 1),
child: const Icon(Icons.add),
);
}),
),
);
}
}
StateTreeWidget
#
Similar to StateTreeBuilder
, StateTreeWidget
creates a State Tree Node and passes it to the build
method.
class Counter extends StateTreeWidget {
const Counter({super.key});
@override
Widget build(BuildContext context, StateTreeNode n) {
final value = n.consume<int>();
return Text('$value');
}
}
class IncrementButton extends StateTreeWidget {
const IncrementButton({super.key});
@override
Widget build(BuildContext context, StateTreeNode n) {
final setValue = n.consume<void Function(int Function(int))>();
return FloatingActionButton(
onPressed: () => setValue((v) => v + 1),
child: const Icon(Icons.add),
);
}
}
class CounterApp extends StatelessWidget {
const CounterApp({super.key});
@override
Widget build(BuildContext context) {
return StateTreeBuilder(
(context, n, child) {
final (value, _, updateValue) = n.state(() => 0);
n.provide(value);
n.provide(updateValue);
return child;
},
child: Scaffold(
body: const Counter(),
floatingActionButton: const IncrementButton(),
),
);
}
}
state
#
Creates a State leaf that holds a value, provides a setter and update function.
StateTreeBuilder((context, n, child) {
final (value, setValue, updateValue) = n.state(() => 0);
// ...
});
value
is 0 after a first build.setValue(42)
–value
is 0.updateValue((v) => v + 1)
–value
is 43.
{!WARNING}
setValue
andupdateValue
couldn't be called during the build phase.
n.state
takes a Record
of dependencies as a second argument.
If dependencies change, value
will be reset:
StateTreeBuilder((context, n, child) {
final (dep, setDep, _) = n.state(() => Object(), (dep,));
final (value, _, updateValue) = n.state(() {
print('state called');
return 0;
}, (dep,));
// ...
});
value
is 0 after first build.setValue(42)
–value
is 42setValue(43)
–value
is 43setDep(Object())
–state called
is printed,value
is 0.
effect
#
Creates an Effect leaf that runs a function on first build and when dependencies change.
StateTreeBuilder((context, n, child) {
final (dep, setDep) = n.state(() => Object(), (dep,));
n.effect(() {
print('effect called');
}, (dep,));
// ...
});
effect called
is printed after first build.effect called
is printed every timesetDep
is called with a different value.
provide
#
Provides a value to the descendants.
Use consume
to read the value further down the tree.
StateTreeBuilder(
(context, n, child) {
n.provide(42);
// ...
},
child: StateTreeBuilder((context, n, child) {
final value = n.consume<int>();
return Text(value.toString()); // "42"
}),
);
Every time provided value changes, all nodes that consumed the value are rebuilt.
class StringConsumer extends StateTreeWidget {
const StringConsumer({super.key});
@override
Widget build(BuildContext context, StateTreeNode n) {
final value = n.consume<String>();
return Text(value);
}
}
class IntConsumer extends StateTreeWidget {
const IntConsumer({super.key});
@override
Widget build(BuildContext context, StateTreeNode n) {
final value = n.consume<int>();
return Text(value.toString());
}
}
StateTreeBuilder(
(context, n, child) {
final (value, _, updateValue) = n.state(() => 0);
n.provide(value);
n.provide(updateValue);
// ...
},
child: StateTreeBuilder(
(context, n, child) {
final (value, setValue, _) = n.state(() => "hello");
n.provide(value);
n.provide(updateValue);
return child;
},
child: Column(
children: const [
StringConsumer(),
IntConsumer(),
StateTreeBuilder((context, n, child) {
final update = n.consume<void Function(String Function(String))>();
return ElevatedButton(
onPressed: () => setValue("world"),
child: const Text("Update string value"),
);
}),
StateTreeBuilder((context, n, child) {
final update = n.consume<void Function(int Function(int))>();
return ElevatedButton(
onPressed: () => update((v) => v + 1),
child: const Text("Update int value"),
);
}),
],
),
),
);
StringConsumer
showshello
after first build.IntConsumer
shows0
after first build.- Pressing
Update string value
button triggersStringConsumer
rebuild, it shows"world"
now. - Pressing
Update int value
button triggersIntConsumer
rebuild, it shows1
now.
Provider shadowing #
When multiple values of the same type are provided, consume
returns the closes value.
StateTreeBuilder(
(context, n, child) {
n.provide(42);
// ...
return child;
},
child: StateTreeBuilder(
(context, n, child) {
n.provide(15); closes int <-------------|
|
return child; |
}, |
child: StateTreeBuilder((context, n, _) { |
final value = n.consume<int>(); --------|
return Text(value.toString());
}),
),
);
To distinguish between values of the same type, use token
parameter of provide
.
StateTreeBuilder(
(context, n, child) {
n.provide(42, token: #outer); <----------------------|
|
return child; |
}, |
child: StateTreeBuilder( |
(context, n, child) { |
n.provide(15, token: #inner); <------------------| |
| |
return child; | |
}, | |
child: StateTreeBuilder((context, n, _) { | |
final inner = n.consume<int>(token: #inner); ----| |
final outer = n.consume<int>(token: #outer); ------|
return Text('$outer $inner');
}),
),
);
consume
#
Reads a value provided by the ancestors. Every time provided value changes, the node is rebuilt.
StateTreeBuilder(
(context, n, child) {
n.provide(42);
// ...
return child;
},
child: StateTreeBuilder((context, n, child) {
final value = n.consume<int>();
return Text(value.toString());
}),
);
See Provider shadowing to learn how to read multiple values of the same type.
query
#
Reads a value provided by the ancestors and transforms it with a function. Triggers rebuild only if transformed value changes.
This helps to avoid unnecessary rebuilds:
class FileTile extends StatelessWidget {
final String path;
const FileTile({super.key, required this.path});
@override
Widget build(BuildContext context) {
return StateTreeBuilder(
(context, n, child) {
final isSelected = n.query<bool, String>(
(path) => path == this.path,
token: #selectedPath,
);
return ListTile(
title: child,
selected: isSelected,
);
},
child: Text(path),
)
}
}
StateTreeBuilder(
(context, n, child) {
final files = ["path/to/1", "path/to/2", "path/to/3", "path/to/4"];
final selectedPath = "path/to/1";
n.provide(files, token: #files);
n.provide(selectedPath, token: #selectedPath);
// ...
return child;
},
child: StateTreeBuilder((context, n, child) {
final files = n.consume<List<String>>(token: #files);
return ListView.builder(
itemCount: files.length,
itemBuilder: (context, index) {
return FileTile(path: files[index]);
},
);
}),
);
In the example above, query
helps to reduce the number of rebuilds of FileTile
widgets.
If FileTile
used consume
instead of query
, every time selectedPath
changes, all FileTile
widgets would be rebuilt:
StateTreeBuilder((context, n, child) {
// triggers every item to rebuild.
final selectedPath = n.consume<String>(token: #selectedPath);
return ListTile(
title: child,
selected: selectedPath == path,
);
})
query
reduces the number of rebuilds to two:
- previously selected item should be rebuilt.
- newly selected item should be rebuilt.
fold
#
- returns
initialValue
on first build. - calls reducer function with
initialValue
and provided value every time provided value changes and returns the result. - resets to
initialValue
if dependencies change.
StateTreeBuilder(
(context, n, child) {
final (dep, setDep, _) = n.state(() => Object());
final (intent, setIntent, _) = n.state<Intent>(() => DoNothingIntent());
final result = n.fold<int, Intent>(0, intent, (acc, v) {
return switch (v) {
Increment(delta: final delta) => acc + delta,
Decrement(delta: final delta) => acc - delta,
_ => acc,
};
}, (dep,));
n.provide(result);
return Actions(
actions: {
Increment: CallbackAction(onInvoke: setIntent),
Decrement: CallbackAction(onInvoke: setIntent),
Reset: CallbackAction(onInvoke: (_) => setDep(Object())),
},
child: child,
);
},
);
License #
MIT