A BLoC that just loads and displays a list isn’t hard. The challenge comes when you need to manage session-based workflows in a single BLoC – things like “create a session -> add questions -> receive answers -> complete.”
Draw the States First
Before coding the BLoC, define the states first. Listing all the states the UI needs to display for this workflow:
- Initial (nothing loaded)
- Session list loading
- Session list displayed
- Creating new session
- Session detail loading
- Session detail displayed (with question list)
- Adding question
- Submitting answer
- Error
abstract class ReviewQaState {}
class ReviewQaInitial extends ReviewQaState {}
class ReviewQaLoading extends ReviewQaState {}
class ReviewQaSessionListLoaded extends ReviewQaState {
final List<QaSession> sessions;
ReviewQaSessionListLoaded(this.sessions);
}
class ReviewQaSessionLoaded extends ReviewQaState {
final QaSession session;
final List<ReviewQuestion> questions;
ReviewQaSessionLoaded({required this.session, required this.questions});
}
class ReviewQaQuestionAdded extends ReviewQaState {
final ReviewQuestion question;
ReviewQaQuestionAdded(this.question);
}
class ReviewQaError extends ReviewQaState {
final String message;
ReviewQaError(this.message);
}
State classes need to be this specific so the UI can branch clearly with if (state is ReviewQaSessionLoaded).
Event Design
Create events that correspond to each state.
abstract class ReviewQaEvent {}
class LoadQaSessions extends ReviewQaEvent {
final int listingId;
LoadQaSessions(this.listingId);
}
class CreateQaSession extends ReviewQaEvent {
final int listingId;
final String title;
CreateQaSession({required this.listingId, required this.title});
}
class LoadQaSessionDetail extends ReviewQaEvent {
final int sessionId;
LoadQaSessionDetail(this.sessionId);
}
class AddQuestion extends ReviewQaEvent {
final int sessionId;
final String content;
AddQuestion({required this.sessionId, required this.content});
}
class SubmitAnswer extends ReviewQaEvent {
final int questionId;
final String answer;
SubmitAnswer({required this.questionId, required this.answer});
}
BLoC Implementation - mapEventToState
Handlers for each event.
class ReviewQaBloc extends Bloc<ReviewQaEvent, ReviewQaState> {
final ReviewQaRepository _repository;
ReviewQaBloc({required ReviewQaRepository repository})
: _repository = repository,
super(ReviewQaInitial()) {
on<LoadQaSessions>(_onLoadSessions);
on<CreateQaSession>(_onCreateSession);
on<LoadQaSessionDetail>(_onLoadDetail);
on<AddQuestion>(_onAddQuestion);
on<SubmitAnswer>(_onSubmitAnswer);
}
Future<void> _onLoadSessions(
LoadQaSessions event,
Emitter<ReviewQaState> emit,
) async {
emit(ReviewQaLoading());
try {
final sessions = await _repository.getSessions(event.listingId);
emit(ReviewQaSessionListLoaded(sessions));
} catch (e) {
emit(ReviewQaError(e.toString()));
}
}
Future<void> _onAddQuestion(
AddQuestion event,
Emitter<ReviewQaState> emit,
) async {
// Don't transition to loading state -- keep current state
// because the existing list should remain visible while adding a question
final currentState = state;
try {
final newQuestion = await _repository.addQuestion(
sessionId: event.sessionId,
content: event.content,
);
emit(ReviewQaQuestionAdded(newQuestion));
// Reload detail after adding
if (currentState is ReviewQaSessionLoaded) {
add(LoadQaSessionDetail(currentState.session.id));
}
} catch (e) {
emit(ReviewQaError(e.toString()));
}
}
}
Caution: Don’t Overuse the Loading State
You shouldn’t emit(ReviewQaLoading()) on every event.
Having the entire list disappear and a spinner appear while adding a question is the worst UX.
ReviewQaLoading should only be used for “initial loading where it’s okay to replace the entire screen.”
For granular actions (adding questions, submitting answers), it’s better to include a separate local state or isSubmitting flag within the BLoC state.
class ReviewQaSessionLoaded extends ReviewQaState {
final QaSession session;
final List<ReviewQuestion> questions;
final bool isAddingQuestion; // Whether a question is being added
ReviewQaSessionLoaded({
required this.session,
required this.questions,
this.isAddingQuestion = false,
});
ReviewQaSessionLoaded copyWith({
QaSession? session,
List<ReviewQuestion>? questions,
bool? isAddingQuestion,
}) {
return ReviewQaSessionLoaded(
session: session ?? this.session,
questions: questions ?? this.questions,
isAddingQuestion: isAddingQuestion ?? this.isAddingQuestion,
);
}
}
Future<void> _onAddQuestion(...) async {
if (state is ReviewQaSessionLoaded) {
final current = state as ReviewQaSessionLoaded;
emit(current.copyWith(isAddingQuestion: true)); // Keep list, only change flag
try {
final newQuestion = await _repository.addQuestion(...);
final updatedQuestions = [...current.questions, newQuestion];
emit(current.copyWith(
questions: updatedQuestions,
isAddingQuestion: false,
));
} catch (e) {
emit(current.copyWith(isAddingQuestion: false));
// Error handling
}
}
}
Branching in the UI
BlocBuilder<ReviewQaBloc, ReviewQaState>(
builder: (context, state) {
if (state is ReviewQaLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is ReviewQaSessionLoaded) {
return Column(
children: [
ListView.builder(
itemCount: state.questions.length,
itemBuilder: (_, i) => QuestionCard(state.questions[i]),
),
if (state.isAddingQuestion)
const LinearProgressIndicator() // Thin progress bar instead of full spinner
else
AddQuestionButton(
onPressed: () => context.read<ReviewQaBloc>().add(
AddQuestion(sessionId: state.session.id, content: _controller.text),
),
),
],
);
}
if (state is ReviewQaError) {
return ErrorView(message: state.message);
}
return const SizedBox.shrink();
},
)
Summary
- Splitting state classes into specific types makes UI branching clear
- Distinguishing between full-screen loading and granular action loading is essential for good UX
- The
copyWithpattern – keeping current state while changing only parts – is the key technique - Whether to auto-reload details after event processing or use optimistic updates depends on server response speed

💬 댓글
비밀번호를 기억해두면 나중에 내 댓글을 삭제할 수 있어요.