Dart 3.13 Primary Constructors + BlocSignal: Boilerplate-Free Reactive Architecture
Discover how Dart 3.13 primary constructors, 'this' constructor bodies, and constructor shorthands transform BlocSignal into the cleanest state management architecture in Flutter.
개요 #
BLoC 패턴을 써본 개발자라면 익숙한 불만이 하나 있다. 보일러플레이트다. 이벤트 클래스를 선언하고, 상태 계층을 만들고, 생성자 파라미터와 private 필드를 짝지어 쓰고, super 초기화 목록을 채우고, 이벤트 핸들러를 등록한다. 실제 사용자 동작 하나를 처리하기 전에 50줄이 사라진다.
Dart 3.13이 이 지점을 정면으로 건드렸다. 프라이머리 컨스트럭터(primary constructors), this 생성자 본문 블록, 그리고 new/factory 생성자 축약 문법이 함께 들어왔다. Randal L. Schwartz는 이 세 기능을 BlocSignal과 조합했을 때 어떤 코드가 나오는지를 단계별로 보여준다. BlocSignal은 시그널 기반으로 동기 갱신을 처리하는 BLoC 계열 상태 관리 라이브러리다.
핵심은 언어 기능과 라이브러리 설계가 맞아떨어졌다는 점이다. 클래스 헤더 하나로 필드 선언과 super 초기화를 동시에 끝내고, 생성자 본문이 필요한 이벤트 핸들러 등록은 this 블록으로 분리한다.
이벤트와 상태 선언이 한 줄로 #
기존 BLoC에서 불변 이벤트 계층을 만들려면 서브타입마다 생성자 시그니처와 필드 정의를 반복해야 했다.
Dart 3.13 이전:
sealed class UserEvent {}
class UserFetchRequested extends UserEvent {
final String userId;
UserFetchRequested(this.userId);
}
class UserUpdated extends UserEvent {
final String name;
final int age;
UserUpdated({required this.name, required this.age});
}
class UserLoggedOut extends UserEvent {}프라이머리 컨스트럭터 적용 후:
sealed class UserEvent {}
class UserFetchRequested(final String userId) extends UserEvent;
class UserUpdated({required final String name, required final int age}) extends UserEvent;
class UserLoggedOut() extends UserEvent;sealed 계층 전체가 세 줄로 줄었다. 타입 안전성도, switch 표현식의 exhaustiveness 검사도 그대로 유지된다.
Photo by JIUN-JE LIN on Pexels
의존성 주입 코드가 헤더로 올라간다 #
CubitSignal에는 보통 리포지토리나 API 클라이언트, 분석 트래커를 주입한다. 이전 버전에서는 필드를 선언하고, 생성자 인자를 받고, 초기 상태를 super로 넘기는 세 단계를 매번 거쳐야 했다.
Dart 3.13 이전:
class UserCubit extends CubitSignal<UserState> {
final UserRepository _repository;
final AnalyticsService _analytics;
UserCubit({
required UserRepository repository,
required AnalyticsService analytics,
UserState initial = const UserInitial(),
}) : _repository = repository,
_analytics = analytics,
super(initialState: initial);
Future<void> loadUser(String id) async {
emit(const UserLoading());
try {
final user = await _repository.fetchUser(id);
_analytics.track('user_loaded', {'id': id});
emit(UserSuccess(user));
} catch (e, st) {
onError(e, st);
emit(UserError(e.toString()));
}
}
}Dart 3.13:
class UserCubit(
final UserRepository repository,
final AnalyticsService analytics, {
final UserState initial = const UserInitial(),
}) extends CubitSignal<UserState>(initialState: initial) {
Future<void> loadUser(String id) async {
emit(const UserLoading());
try {
final user = await repository.fetchUser(id);
analytics.track('user_loaded', {'id': id});
emit(UserSuccess(user));
} catch (e, st) {
onError(e, st);
emit(UserError(e.toString()));
}
}
}필드를 다시 선언하지 않고, 파라미터 이름을 중복해서 쓰지도 않는다. 주입한 의존성은 클래스 전체 메서드에서 바로 쓸 수 있다.
this 블록으로 이벤트 핸들러 등록 #
Dart 3.13에서 눈여겨볼 기능은 this 생성자 본문 문법이다. 프라이머리 컨스트럭터를 쓰면 생성자 시그니처가 헤더로 올라가는데, on<E>() 핸들러 등록이나 사전 조건 assert처럼 본문 로직이 필요한 경우가 남는다. 이때 클래스 본문 안에 this { ... } 블록을 두면 된다.
class SearchBloc(
final SearchRepository repository, {
final SearchState initial = const SearchInitial(),
}) extends BlocSignal<SearchEvent, SearchState>(initialState: initial) {
// Dart 3.13 프라이머리 컨스트럭터 본문
this {
on<SearchQueryChanged>(
(event, emit) async {
if (event.query.trim().isEmpty) return emit(const SearchEmpty());
emit(const SearchLoading());
final results = await repository.search(event.query);
emit(SearchSuccess(results));
},
transformer: restartable(), // Zero-stream event concurrency!
);
}
}헤더는 클래스가 무엇을 요구하는지 선언하고, this 블록은 이벤트 파이프라인을 구성한다. 역할이 깔끔하게 갈린다.
createEffect로 상위 상태에 바로 연결 #
BlocSignal에는 createEffect가 있다. 시그널 의존성을 자동으로 추적하고, 컨테이너가 dispose될 때 정리까지 맡는다. 프라이머리 컨스트럭터 파라미터가 스코프 안에 있으니, 파생 큐빗이 this 블록에서 상위 상태 컨테이너와 동기적으로 연결된다.
class CartSummaryCubit(final CartBloc cartBloc)
extends CubitSignal<CartSummary>(initialState: const CartSummary.zero()) {
this {
// cartBloc.state 시그널 변화에 동기적으로 반응한다
createEffect(() {
final items = cartBloc.state.value.items;
final total = items.fold<double>(0, (sum, item) => sum + item.price);
emit(CartSummary(count: items.length, total: total));
});
}
}테스트용 시드 생성자도 짧아졌다 #
생성자 축약 문법도 같이 들어왔다. new name() 형태로 보조 명명 생성자를 정의하면 클래스 이름을 다시 쓸 필요가 없다.
class CounterCubit(var int count) extends CubitSignal<int>(initialState: count) {
// 명명 생성자 축약:
new zero() : this(0);
new seeded(int initial) : this(initial);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
}테스트 변형이나 목 데이터 시드, 기본 설정값을 만들 때 요긴하다.
프로젝트에 적용하기 #
pubspec.yaml에서 SDK 제약을 올린다.
environment:
sdk: ^3.13.0
dependencies:
bloc_signals: ^1.0.0
bloc_signals_flutter: ^1.0.0analysis_options.yaml에 3.13용 린터 규칙을 켠다.
include: package:very_good_analysis/analysis_options.yaml
linter:
rules:
- use_primary_constructors
- use_declaring_parameters
- unnecessary_type_name_in_constructor
- unnecessary_primary_constructor_body원문이 정리한 이점 #
Schwartz는 Dart 3.13과 BlocSignal 조합의 결과를 네 가지로 요약했다.
- 0ms 동기 갱신 — 상태 emit이 마이크로태스크 지연 없이 현재 프레임에서 전파된다.
- 최소한의 형식 — 클래스 헤더가 필드 선언과 super 초기화를 동시에 처리한다.
- 시그널 그래프 효율 —
==비교로 중복 갱신을 걸러내고, UI는 세밀한 단위로만 다시 그린다. - BLoC의 엄격함 유지 — 이벤트 디스패치, 상태 전이, OpenTelemetry 관측성은 그대로다.
문서와 벤치마크, 예제는 blocsignal.dev에서 볼 수 있고, 저장소는 GitHub에 공개돼 있다.
이 글은 위 출처를 바탕으로 한국 독자를 위해 재작성한 기사입니다. 원문의 사실과 수치에 근거하며, 별도의 견해를 포함하지 않습니다.

