Components
Every screen consists of the following 4 parts, and files are 2only creates:
- State — freezed immutable class. All fields needed by the UI.
- Action — freezed sealed class. All actions the user can trigger.
- ViewModel —
ChangeNotifiermixin. Holds State and handles Action. - Root / Screen widget separation — Root handles DI/events/rebuild subscription, Screen handles pure UI.
| File | Contents |
|---|---|
<feature>_view_model.dart |
Top of file: State → Action, bottom of file: ViewModel |
<feature>_screen.dart |
Top of file: Root, bottom of file: Screen |
Optional element: Event (one-time side effect) — one-time signals such as snackbars/navigation that must not be put into state. StreamController emit through.
State
freezed class. Default value is specified as @Default .
// lib/presentation/ingredient/ingredient_view_model.dart (파일 상단)
import 'package:flutter_recipe_app_course/domain/model/ingredient.dart';
import 'package:flutter_recipe_app_course/domain/model/procedure.dart';
import 'package:flutter_recipe_app_course/domain/model/recipe.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'ingredient_view_model.freezed.dart';
part 'ingredient_view_model.g.dart';
@freezed
class IngredientState with _$IngredientState {
const factory IngredientState({
Recipe? recipe,
@Default([]) List<Ingredient> ingredients,
@Default([]) List<Procedure> procedures,
@Default(0) int selectedTabIndex,
}) = _IngredientState;
factory IngredientState.fromJson(Map<String, Object?> json) =>
_$IngredientStateFromJson(json);
}
Rules:
- All UI state exists only in State. Do not scatter UI-related variables as ViewModel instance fields.
- State updates always create a new instance with
copyWith(...)and reassign to_state, then declare withnotifyListeners().
Action
freezed + sealed so that switch enforces all cases.
// lib/presentation/ingredient/ingredient_view_model.dart (State 바로 아래)
import 'package:flutter_recipe_app_course/domain/model/recipe.dart';
@freezed
sealed class IngredientAction with _$IngredientAction {
const factory IngredientAction.onTapFavorite(Recipe recipe) = OnTapFavorite;
const factory IngredientAction.onTapIngredient() = OnTapIngredient;
const factory IngredientAction.onTapProcedure() = OnTapProcedure;
const factory IngredientAction.loadRecipe(int recipeId) = LoadRecipe;
const factory IngredientAction.onTapShareMenu(String link) = OnTapShareMenu;
}
Naming: on<Trigger> or verb form (load...). Each factory also defines the class type (OnTapFavorite) together, and uses it in switch pattern matching.
ViewModel
// lib/presentation/ingredient/ingredient_view_model.dart (Action 바로 아래)
class IngredientViewModel with ChangeNotifier {
final IngredientRepository _ingredientRepository;
final ProcedureRepository _procedureRepository;
final GetDishesByCategoryUseCase _getDishesByCategoryUseCase;
final ClipboardService _clipboardService;
IngredientState _state = const IngredientState();
IngredientState get state => _state;
IngredientViewModel({
required IngredientRepository ingredientRepository,
required ProcedureRepository procedureRepository,
required GetDishesByCategoryUseCase getDishesByCategoryUseCase,
required ClipboardService clipboardService,
}) : _ingredientRepository = ingredientRepository,
_procedureRepository = procedureRepository,
_getDishesByCategoryUseCase = getDishesByCategoryUseCase,
_clipboardService = clipboardService;
void onAction(IngredientAction action) async {
switch (action) {
case LoadRecipe():
_loadRecipe(action.recipeId);
case OnTapIngredient():
_state = state.copyWith(selectedTabIndex: 0);
notifyListeners();
case OnTapProcedure():
_state = state.copyWith(selectedTabIndex: 1);
notifyListeners();
case OnTapShareMenu():
_clipboardService.copyText(action.link);
// ... 나머지 케이스
}
}
}
Rules:
with ChangeNotifieruse the mixin. It is Flutter's standard andListenableBuildercombines naturally with it.- Single public method
onAction(Action action)dispatches withswitch. Do not increase individual public methods — Screen becomes less aware of the ViewModel's type. _state = state.copyWith(...); notifyListeners();keep the order of. Multiple changes within the same frame are merged into a singlecopyWith.- Receive dependencies only through constructor injection. Do not call
getItinside the ViewModel. StreamSubscriptionIf there is@override void dispose(), incancel()must
Stream subscription pattern
UseCase가 Stream If returns, subscribe in the ViewModel constructor or action handler, and dispose cancel in.
class SavedRecipesViewModel with ChangeNotifier {
StreamSubscription? _streamSubscription;
SavedRecipesViewModel({required GetSavedRecipesUseCase getSavedRecipesUseCase})
: _getSavedRecipesUseCase = getSavedRecipesUseCase {
_streamSubscription = _getSavedRecipesUseCase.execute().listen((recipes) {
_state = state.copyWith(recipes: recipes);
notifyListeners();
});
}
@override
void dispose() {
_streamSubscription?.cancel();
super.dispose();
}
}
One-time events (snackbar, navigation)
If expressed as state, it can be re-executed on every rebuild. StreamController let it flow only once through.
class HomeViewModel with ChangeNotifier {
final _eventController = StreamController<NetworkError>();
Stream<NetworkError> get eventStream => _eventController.stream;
// 실패 시
_eventController.add(result.error);
}
In the Root widget, instead of StreamBuilder , subscribe with initState 의 listen and show the Snackbar (because it is a side effect outside the Widget tree).
Root / Screen separation
<feature>_screen.dart Two classes coexist in one file:
- File top:
Root— DI + event subscription +ListenableBuilder - File Bottom:
Screen— pure UI.state와onActionreceived only as props
Root widget
// lib/presentation/ingredient/ingredient_screen.dart (파일 상단)
class IngredientRoot extends StatelessWidget {
final int recipeId;
const IngredientRoot({super.key, required this.recipeId});
@override
Widget build(BuildContext context) {
final viewModel = getIt<IngredientViewModel>();
viewModel.onAction(IngredientAction.loadRecipe(recipeId));
return ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
if (viewModel.state.recipe == null) {
return const Center(child: CircularProgressIndicator());
}
return IngredientScreen(
state: viewModel.state,
onAction: viewModel.onAction,
onTapMenu: (menu) { /* 다이얼로그·네비게이션 */ },
);
},
);
}
}
getIt<>()calls happen only here.ListenableBuilder(listenable: viewModel, ...)로notifyListeners()subscribes to changes.- For initial data loading,
buildinsideonAction(... load)calling is the existing convention of this project. However, to prevent reloading on every rebuild, either prevent duplicate requests inside the ViewModel (ignore if already loaded), or raise Root toStatefulWidgetandinitStatecalling from is also fine. If there is complex initialization,StatefulWidgetpromoting to is safer.
Screen widget
// lib/presentation/ingredient/ingredient_screen.dart (파일 하단)
class IngredientScreen extends StatelessWidget {
final IngredientState state;
final void Function(IngredientAction action) onAction;
final void Function(IngredientMenu menu) onTapMenu;
const IngredientScreen({
super.key,
required this.state,
required this.onAction,
required this.onTapMenu,
});
@override
Widget build(BuildContext context) { /* 순수 UI */ }
}
ViewModelhas no reference to. Tests and widget previews are possible independently.- Interactions that require navigation or dialogs should be separated into additional callbacks like
onTapMenuand handled by Root — Screen should notcontext.godirectly make go_router calls like.
When using callbacks instead of Actions
In Screen, interactions that require context like navigation/dialogs should have a separate onTap* callback and be handled in Root. There are two reasons:
- ViewModel 은
BuildContextdoes not know (testability). - Navigation is not a "state change" but an "effect", so mixing it into Actions makes the model messy.
예: saved_recipes_root.dart 는 onAction Inside the callback, OnTapRecipe intercept context.push(...) and navigate to, passing the remaining Actions to the ViewModel.
onAction: (action) {
if (action is OnTapRecipe) {
context.push('/Home/Ingredient/${action.recipe.id}');
return;
}
viewModel.onAction(action);
},
UI model (when needed)
If display formatting ("3일 전", "20 min") is entangled with the domain model, create a Presentation-only UI model. By default, use the domain model as is, and only when formatting becomes complex separate into a UI model.
Checklist — adding a new screen
-
lib/presentation/<feature>/<feature>_view_model.dart— top: freezed State → sealed Action, bottom:with ChangeNotifierViewModel,onActionsingle entry -
lib/presentation/<feature>/<feature>_screen.dart— top: Root(getIt,ListenableBuilder, event subscription), bottom: Screen (pure UI receiving only state + onAction) -
diSetup()에 ViewModel을registerFactoryregister as - If needed,
StreamControllerexpose one-time events as -
build_runner buildgenerate freezed files with
Anti-patterns
- ❌ Screen widget calls
getIt<>()→ lift it up to the Root widget. - ❌ ViewModel 이
BuildContext나GoRouterreferences → navigation should be a Root callback. - ❌ Having multiple public methods (
loadRecipe(),onTapBack(),onSelectTab()) and calling them from Screen →onAction(Action)narrow down to one. - ❌ After State change,
notifyListeners()missing → rebuild does not happen. - ❌
StreamSubscription을disposenot canceled in → memory leak and ghost callback. - ❌ Putting snackbar messages in state and showing them again on every rebuild →
StreamControlleremit as events.