fst 0.2.0+1 copy "fst: ^0.2.0+1" to clipboard
fst: ^0.2.0+1 copied to clipboard

unlisted

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 a Widget tree at any given moment since every State Tree Node is also a Widget.

Each State Tree Node could have multiple leaf nodes (a 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 updating only relevant parts of the UI.

There are two ways to create a State Tree Node:

  1. Use StateTreeBuilder.
  2. Create a custom widget that extends StateTreeWidget.

Documentation #

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, and provides a setter and update function.

StateTreeBuilder((context, n, child) {
  final (value, setValue, updateValue) = n.state(() => 0);
  // ...
});
  • value is 0 after the first build.
  • setValue(42)value is 0.
  • updateValue((v) => v + 1)value is 43.

⚠️ setValue and updateValue couldn't be called during the build phase.

n.state takes a Record of dependencies as a second argument. If dependencies change, the 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 the first build.
  • setValue(42)value is 42
  • setValue(43)value is 43
  • setDep(Object())state called is printed, value is 0.

effect #

Creates an Effect leaf that runs a function on the 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 the first build.
  • effect called is printed every time setDep 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 the 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 shows hello after the first build.
  • IntConsumer shows 0 after the first build.
  • Pressing Update string value button triggers StringConsumer rebuild, it shows "world" now.
  • Pressing Update int value button triggers IntConsumer rebuild, it shows 1 now.

Provider shadowing #

When multiple values of the same type are provided, consume returns the closest 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 the 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 the 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 the first build.
  • calls reducer function with initialValue and provided value; returns the result of reducer.
  • 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

2
likes
0
points
61
downloads

Publisher

verified publisherlesnitsky.dev

Weekly Downloads

Perfect cohabitation of Widget and State trees.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter, state_tree

More

Packages that depend on fst