This guide builds a login form whose submit action is a CommandShard. A command models an explicit, user-triggered action, so it's a natural fit for "submit", "create", "update", and "delete".
Along the way you'll see:
ShardListener. Navigation is a side effect, so it stays out of any builder.We'll build it in clear steps: command + model → provide → UI.
A CommandShard<Arg, Res> runs an async function each time you call execute(arg). Define the argument type (LoginForm) and let the command return the result (User).
class LoginForm {
final String email;
final String password;
const LoginForm(this.email, this.password);
}
// CommandShard<Arg, Res>: the action runs on execute(arg).
class LoginCommand extends CommandShard<LoginForm, User> {
LoginCommand(AuthApi api) : super((form) => api.login(form.email, form.password));
}
The command's state is an AsyncValue<User>: it starts idle, becomes loading while the function runs, and ends as data (the User) or error.
Provide the command above the screen that uses it with ShardProvider. See Widgets for placement details.
ShardProvider<LoginCommand>(
create: () => LoginCommand(context.read<AuthApi>()),
child: const LoginScreen(),
)
Wrap the screen in a ShardListener so we can navigate on success, then build the submit button and result area below. The button reads state.isLoading to disable itself while the command is in flight (the double-submit guard), and AsyncShardBuilder renders each phase of the lifecycle.
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context) {
final command = context.read<LoginCommand>();
return Scaffold(
// Navigate on success via a listener (a side effect — not in a builder):
body: ShardListener<LoginCommand, AsyncValue<User>>(
listenWhen: (prev, curr) => curr.hasData && !prev.hasData,
listener: (context, prev, curr) =>
Navigator.of(context).pushReplacementNamed('/home'),
child: Column(
children: [
// ...email/password fields elided...
ShardBuilder<LoginCommand, AsyncValue<User>>(
builder: (context, state) => ElevatedButton(
onPressed: state.isLoading
? null
: () => command.execute(const LoginForm('a@b.com', 'pw')),
child: state.isLoading
? const Text('Signing in…')
: const Text('Sign in'),
),
),
AsyncShardBuilder<LoginCommand, User>(
shard: command,
onIdle: (context) => const SizedBox.shrink(),
onLoading: (context) => const LinearProgressIndicator(),
onData: (context, user) => Text('Welcome, ${user.name}'),
onError: (context, error, stackTrace) =>
Text('Login failed: $error', style: const TextStyle(color: Colors.red)),
),
],
),
),
);
}
}
A few things worth calling out:
command.execute(...) returns a Future<User?>—it resolves to null on failure or when the double-submit guard ignores a tap while the command is already running.onIdle renders nothing before the first submit, so the result area stays empty until the user acts.listenWhen fires the navigation exactly once, on the transition into a data state.command.reset() to return the command to idle, then execute(...) again.| Concept | Where |
|---|---|
| CommandShard<Arg, Res> | Models the submit action; runs on execute(form). |
| onIdle / onLoading / onData / onError | AsyncShardBuilder renders each phase of the command lifecycle. |
| ShardListener | Navigates on success—a side effect kept out of any builder. |
| Double-submit guard | The button is disabled while state.isLoading, so it can't fire twice. |
Next: Best Practices.