Skip to content

Tutorial: a filterable list

In about fifteen minutes you’ll build a list that narrows live as you type — touching the three things every Fleury app is made of: state, layout, and input.

Continue in the my_app project from Getting started. If you skipped it, create the project now:

Terminal window
fleury create my_app --dependency-source=git
cd my_app

The code below imports package:my_app and names its root widget MyApp, the class the generated bin/run_app.dart starts. With a different project name, use your package name in the imports and the class name from your bin/run_app.dart.

This — a text field over a filtered list with a live count. It’s the finished app, running in your browser; type into it and watch the list narrow:

Live · interactive
click & type to interact ⓘ how this demo runs

Start with a stateless screen that just renders some data — replace everything in lib/app.dart:

lib/app.dart
import 'package:fleury/fleury.dart';
const _languages = [
'Dart', 'Rust', 'Go', 'Python', 'TypeScript',
'Elixir', 'Zig', 'Swift', 'Kotlin', 'Haskell',
];
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const FleuryApp(title: 'Filter', home: FilterApp());
}
}
class FilterApp extends StatelessWidget {
const FilterApp({super.key});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(1),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final name in _languages) Text(name),
],
),
);
}

The existing test also needs to follow the screen you just replaced. Use this small smoke test for the rest of the tutorial:

test/app_test.dart
import 'package:fleury_test/fleury_test.dart';
import 'package:my_app/app.dart';
import 'package:test/test.dart';
void main() {
testWidgets('shows the language list', (tester) {
tester.pumpWidget(const MyApp());
expect(tester.renderToString(emptyMark: ' '), contains('Dart'));
});
}

Run dart test to check it. Then start the app with fleury run (or press F5 in VS Code). The list appears one item per row, padded a cell off the edge. Nothing moves yet — let’s make it react.

Leave the app running for the rest of the tutorial. Saving reloads it in place and keeps its state, so you see most steps as soon as you save them; step 2 is the one change that needs a restart. (Ctrl+C quits.)

Filtering means the screen changes over time, so we need a StatefulWidget and a place to keep the query. Swap FilterApp for a stateful version:

class FilterApp extends StatefulWidget {
const FilterApp({super.key});
@override
State<FilterApp> createState() => _FilterAppState();
}
class _FilterAppState extends State<FilterApp> {
String _query = '';
List<String> get _matches => _languages
.where((name) => name.toLowerCase().contains(_query.toLowerCase()))
.toList();
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(1),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final name in _matches) Text(name),
],
),
);
}

_matches derives the visible list from _query every build. _query is still always '', so nothing’s filtered — until we wire up the input. (Until then the analyzer suggests making _query final; step 3 assigns it.)

Turning a stateless widget into a stateful one is a change a reload can’t apply, so restart after saving this step: in a fleury run session, press Ctrl+G, then F5, or use the editor’s Restart action if you launched with VS Code’s F5. If F5 doesn’t reach the app, see When F5 doesn’t restart.

TextInput.onChanged reports the current text. Store that value in _query with setState, and the list filters as you type:

class _FilterAppState extends State<FilterApp> {
String _query = '';
List<String> get _matches => _languages
.where((name) => name.toLowerCase().contains(_query.toLowerCase()))
.toList();
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(1),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextInput(
autofocus: true,
placeholder: 'Filter languages…',
onChanged: (value) => setState(() => _query = value),
),
const SizedBox(height: 1),
for (final name in _matches) Text(name),
],
),
);
}

Save, and the field appears above the list; type, and the list narrows. onChanged calls setState on every edit; build recomputes _matches from _query, and Fleury writes only the cells that changed to the terminal. That’s the whole reactive loop. autofocus: true gives the field keyboard focus as soon as it appears, so you can type straight away.

Two touches make it feel finished: a count of the matches, and an empty state when nothing matches. Expanded gives the list area the rest of the height under the field. Here’s the final build:

@override
Widget build(BuildContext context) {
final matches = _matches;
return Padding(
padding: const EdgeInsets.all(1),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextInput(
autofocus: true,
placeholder: 'Filter languages…',
onChanged: (value) => setState(() => _query = value),
),
const SizedBox(height: 1),
Text(
'${matches.length} of ${_languages.length}',
style: const CellStyle(dim: true),
),
const SizedBox(height: 1),
Expanded(
child: matches.isEmpty
? const Text('No matches', style: CellStyle(dim: true))
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [for (final name in matches) Text(name)],
),
),
],
),
);
}

Save once more. The count updates as you type, and a query with no hits (try zz) shows the empty state. In a terminal shorter than the list, the rows that don’t fit are cut off; a ListView scrolls instead, which Lists & scrolling covers.

That’s a complete, interactive Fleury app: state held in a State, an input reported through onChanged, a derived list, and a layout that fills the screen. Here is the finished lib/app.dart:

import 'package:fleury/fleury.dart';
const _languages = [
'Dart',
'Rust',
'Go',
'Python',
'TypeScript',
'Elixir',
'Zig',
'Swift',
'Kotlin',
'Haskell',
];
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const FleuryApp(title: 'Filter', home: FilterApp());
}
}
class FilterApp extends StatefulWidget {
const FilterApp({super.key});
@override
State<FilterApp> createState() => _FilterAppState();
}
class _FilterAppState extends State<FilterApp> {
String _query = '';
List<String> get _matches => _languages
.where((name) => name.toLowerCase().contains(_query.toLowerCase()))
.toList();
@override
Widget build(BuildContext context) {
final matches = _matches;
return Padding(
padding: const EdgeInsets.all(1),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextInput(
autofocus: true,
placeholder: 'Filter languages…',
onChanged: (value) => setState(() => _query = value),
),
const SizedBox(height: 1),
Text(
'${matches.length} of ${_languages.length}',
style: const CellStyle(dim: true),
),
const SizedBox(height: 1),
Expanded(
child: matches.isEmpty
? const Text('No matches', style: CellStyle(dim: true))
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [for (final name in matches) Text(name)],
),
),
],
),
);
}
}
  • Make the list scrollable and selectable with the arrow keys: Lists & scrolling covers ListView and ListController.
  • Test the filtering: Testing shows how to fill a text field from a test and check what renders.
  • Swap the plain Column of Text for a richer widget — a Tree, DataTable, or Select — from the widget reference. They are already available from your Fleury import.
  • Preview the app in a browser on macOS or Linux: fleury serve --spawn runs it and streams it to a tab, as in step 5 of Getting started.
  • Browse the guides for forms, navigation, key handling, and more.