사전 기반 지식
1. 왜 글쓰기 폼에서 상태관리가 필요한가?
// 나쁜 예: 상태관리 없이 구현하면...
void writePost() {
// 버튼을 여러 번 눌러도 막을 방법이 없음
// 성공/실패 상태를 관리하기 어려움
// 다른 화면과 데이터 동기화 불가능
}
// 좋은 예: 상태관리로 구현하면...
enum PostWriteStatus { initial, loading, success, failure }
// 각 단계별 상태를 명확히 관리
// UI가 상태에 따라 자동으로 업데이트
2. Riverpod의 핵심 개념
- Provider: 상태를 제공하는 객체
- Notifier: 상태를 변경하는 로직을 담당
- Consumer: Provider의 상태를 감시하는 위젯
재사용 가능한 위젯 개선
CustomTextFormField
import 'package:flutter/material.dart';
// 재사용 가능한 위젯으로 설계 하기 위함.
// 맞춤 기능을 추가 하기 위해 재 설계 한다.
class CustomTextFormField extends StatelessWidget {
final String hint;
final bool obscureText;
final TextEditingController controller;
final String? initValue; // 초기 값 (CustomTextFormField - 글쓰기, 글 수정)
//CustomTextFormField() --> 추가 (String) { ... 구현 부 정의 }
final String? Function(String?)? validator;
const CustomTextFormField({
Key? key,
required this.hint,
this.obscureText = false,
required this.controller,
this.initValue = "",
this.validator, // 선택적 매개 변수 (옵션값) - 유효성 검사
}) : super(key: key);
@override
Widget build(BuildContext context) {
if (initValue != null && initValue!.isNotEmpty) {
controller.text = initValue!;
}
return TextFormField(
validator: validator,
controller: controller,
obscureText: obscureText,
decoration: InputDecoration(
hintText: "Enter $hint",
enabledBorder: OutlineInputBorder(
// 3. 기본 TextFormField 디자인
borderRadius: BorderRadius.circular(20),
),
focusedBorder: OutlineInputBorder(
// 4. 손가락 터치시 TextFormField 디자인
borderRadius: BorderRadius.circular(20),
),
errorBorder: OutlineInputBorder(
// 5. 에러발생시 TextFormField 디자인
borderRadius: BorderRadius.circular(20),
),
focusedErrorBorder: OutlineInputBorder(
// 5. 에러가 발생 후 손가락을 터치했을 때 TextFormField 디자인
borderRadius: BorderRadius.circular(20),
),
),
);
}
}
CustomTextArea
import 'package:flutter/material.dart';
class CustomTextArea extends StatelessWidget {
final String hint;
final TextEditingController controller;
final String? Function(String?)? validator;
const CustomTextArea(
{Key? key, required this.hint, required this.controller, this.validator})
: super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: TextFormField(
validator: validator,
controller: controller,
maxLines: 15,
decoration: InputDecoration(
hintText: "Enter $hint",
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
),
),
);
}
}
CustomTextButton
import 'package:flutter/material.dart';
class CustomTextButton extends StatelessWidget {
final String text;
// 비활성화 기능 추가하기 위해서 nullable 변경 처리 함.
// 중복 클릭 방지 용
final VoidCallback? click;
CustomTextButton({required this.text, required this.click});
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: click,
child: Text(text,
style: const TextStyle(
color: Colors.black87, decoration: TextDecoration.underline)),
);
}
}
post_list_notifier.dart 파일에 코드 추가
// 게시글 목록 새로 고침 기능 추가
Future<void> refreshAfterWriter() async {
Logger().d("게시글 작성 후 목록 새로고침 시작");
await fetchPosts(page: 0);
}
게시글 작성 상태 관리 생성
import 'package:flutter/cupertino.dart';
import 'package:flutter_blog/data/models/post.dart';
import 'package:flutter_blog/data/models/repository/post_repository.dart';
import 'package:flutter_blog/main.dart';
import 'package:flutter_blog/providers/global/post/post_list_notifier.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 게시글 작성 진행 상태를 나타내는 열거형
enum PostWriteStatus {
initial, // 초기 상태 (아무것도 하지 않은)
loading, // 작성 중(서버 통신 중)
success, // 게시글 작성 성공
failure, // 게시글 작성 실패
}
// 게시글 작성 상태를 설계 (창고 데이터)
class PostWriteModel {
final PostWriteStatus status; // 현재 게시글 작성 진행 상태
final String? message; // 사용자에게 보여줄 메시지
final Post? createdPost;
PostWriteModel({
this.status = PostWriteStatus.initial,
this.message,
this.createdPost,
}); // 작성 성공시 생성된 게시글
// 불변성 패턴
PostWriteModel copyWith({
PostWriteStatus? status,
String? message,
Post? createdPost,
}) {
return PostWriteModel(
status: status ?? this.status,
message: message ?? this.message,
createdPost: createdPost ?? this.createdPost,
);
}
@override
String toString() {
return 'PostWriteModel{status: $status, message: $message, createdPost: $createdPost}';
}
} // end of PostWriteModel
// 창고 매뉴얼 설계 + 가능한 순수 비즈니스 로직 담당 (단 SRP - 단일 책임에 원칙)
class PostWriteNotifier extends Notifier<PostWriteModel> {
@override
PostWriteModel build() {
return PostWriteModel();
}
Future<void> writePost(String title, String content) async {
// 기본적으로 try .. 사용해야 한다 (서버가 불량인 경우를 위해)
// 생략..
// 중복 클릭 방지를 위해서 상태 변경을 한다.
state = state.copyWith(status: PostWriteStatus.loading);
// UI 단에서 loading 상태라면 VoidCallback 값을 null 처리면 비활성화 버튼으로 변경이 된다.
Map<String, dynamic> response =
await PostRepository().write(title, content);
if (response['success'] == true) {
Post createdPost = Post.fromMap(response['response']);
state = state.copyWith(
status: PostWriteStatus.success,
message: "게시글이 작성되었습니다",
createdPost: createdPost,
);
} else {
state = state.copyWith(
status: PostWriteStatus.failure,
message: "${response['errorMessage']} - 게시글 작성에 실패했습니다",
);
}
}
} // end of notifier
final postWriteProvider = NotifierProvider<PostWriteNotifier, PostWriteModel>(
() => PostWriteNotifier());
게시글 작성 폼과 상태관리 연동
import 'package:flutter/material.dart';
import 'package:flutter_blog/_core/constants/size.dart';
import 'package:flutter_blog/_core/utils/validator_util.dart';
import 'package:flutter_blog/providers/global/post/post_list_notifier.dart';
import 'package:flutter_blog/providers/global/post/post_write_notifier.dart';
import 'package:flutter_blog/ui/widgets/custom_elavated_button.dart';
import 'package:flutter_blog/ui/widgets/custom_text_area.dart';
import 'package:flutter_blog/ui/widgets/custom_text_form_field.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class PostWriteForm extends ConsumerWidget {
final _formKey = GlobalKey<FormState>(); // 폼 유효성 검사용 키
final _title = TextEditingController(); // 제목 입력 컨트롤러
final _content = TextEditingController(); // 컨텐츠 입력 컨트롤러
PostWriteForm({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
// 게시글 작성 상태 데이터
// 상태가 변경될 때 마다 build 메서드 다시 호출되어 UI를 업데이트 처리 한다.
final PostWriteModel model = ref.watch(postWriteProvider);
// 복습 정리
// 상태 관련 메서드
// ref.read(provider), ref.watch(provider)
// ref.listen 활용해보자.
// 1. build 메서드 내부에서 또는 initState 내부 에서 사용 가능
// 2. 상태 변화에 따른 사이드 이펙트 처리
// 참고 : 주로 네이게이션, 다이얼로그, 스낵바 등 일회성 액션이 필요할 때 사용함
ref.listen(
postWriteProvider,
(previous, next) {
if (next.status == PostWriteStatus.success) {
// 사이드 이펙트 1 : 성공 메시지 표시
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('게시글 작성 완료'),
backgroundColor: Colors.green,
),
);
// 사이드 이펙트 2 : 게시글 목록 새로 고침 ( 가능한 notifier 끼리 통신은 자제 )
ref.read(postListProvider.notifier).refreshAfterWriter();
// 사이드 이펙트 3 : 화면 이동
Navigator.pop(context);
} else if (next.status == PostWriteStatus.failure) {
// 사이드 이펙트 1 : 성공 메시지 표시
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('게시글 작성 싥패'),
backgroundColor: Colors.redAccent,
),
);
}
},
);
return Form(
key: _formKey,
child: ListView(
shrinkWrap: true,
children: [
CustomTextFormField(
controller: _title,
hint: "Title",
validator: (value) =>
value?.trim().isEmpty == true ? "제목을 입력하세요" : null,
),
const SizedBox(height: smallGap),
CustomTextArea(
controller: _content,
hint: "Content",
validator: (value) =>
value?.trim().isEmpty == true ? "내용을 입력해주세요" : null,
),
const SizedBox(height: largeGap),
CustomElevatedButton(
text: model.status == PostWriteStatus.loading ? "작성중..." : "글쓰기",
click: model.status == PostWriteStatus.loading
? null
: () => _handleSubmit(ref)),
],
),
);
} // end of build
void _handleSubmit(WidgetRef ref) {
// 유효성 검사
// 사용자가 작성한 값 들고 오기
// 상태관리 비즈니스 로직을 호출(게시글 작성)
if (_formKey.currentState!.validate()) {
final title = _title.text.trim();
final content = _content.text.trim();
ref.read(postWriteProvider.notifier).writePost(title, content);
}
}
}
사이드 이펙트(Side Effect)란?
사이드 이펙트는 함수나 메서드가 주요 목적 외에 추가로 발생시키는 작업을 의미합니다.
// 일반 로직 (순수 함수)
int add(int a, int b) {
return a + b; // 입력받은 값으로 계산만 함
}
// 사이드 이펙트가 있는 함수
int addAndLog(int a, int b) {
int result = a + b; // 주요 목적
// 사이드 이펙트들
print("계산 완료: $result"); // 콘솔 출력
_database.save(result); // 데이터베이스 저장
_analytics.track('add_used'); // 분석 데이터 전송
return result;
}
ref.listen(postWriteProvider, (previous, next) {
if (next.status == PostWriteStatus.success) {
// 이 모든 작업들이 사이드 이펙트
Navigator.pop(context); // 1. 화면 네비게이션
ScaffoldMessenger.of(context) // 2. 사용자 알림
.showSnackBar(...);
_clearForm(); // 3. 폼 초기화
_trackAnalytics('post_created'); // 4. 분석 데이터 전송
}
});
핵심 개념 정리
1. 중복 클릭 방지 패턴
// 상태에 따른 버튼 제어
text: isLoading ? "처리 중..." : "액션",
click: isLoading ? null : () => action(),
// Flutter에서 onPressed가 null이면 자동으로 버튼 비활성화
2. 상태 기반 UI 업데이트
// ref.watch(): 상태 감시 → UI 리빌드
final state = ref.watch(provider);
// ref.listen(): 상태 변화 감지 → 사이드 이펙트 실행
ref.listen(provider, (previous, next) {
// 네비게이션, 다이얼로그, 스낵바 등
});
3. Provider 간 통신
// A Provider에서 B Provider의 메서드 호출
await ref.read(otherProvider.notifier).someMethod();
4. 불변성과 copyWith 패턴
// 기존 상태는 변경하지 않고 새로운 상태 객체 생성
state = state.copyWith(
status: NewStatus.loading,
message: null,
);
'Flutter' 카테고리의 다른 글
| 블로그 프로젝트 - 게시글 수정하기 (1) | 2025.09.05 |
|---|---|
| 블로그 프로젝트 - 게시글 상세 보기 구현 하기 (1) | 2025.09.05 |
| 블로그 프로젝트 - 게시글 목록 구현 하기 (0) | 2025.09.04 |
| 블로그 프로젝트 - 로그아웃 기능 연결 및 UI 수정 (0) | 2025.09.04 |
| 블로그 프로젝트 - 사용자 세션(로그인 상태) 관리 하기 (1) | 2025.09.01 |