redux_epics 는 반응형 스트림을 통해 부작용을 처리할 수 있도록 해주는 redux.dart 상태 관리 라이브러리용 미들웨어입니다. Dart의 Stream API를 사용하여 API 호출이나 탐색과 같은 비동기 작업을 선언적이고 테스트 가능한 방식으로 관리할 수 있습니다. Redux의 디스패치 메커니즘과 통합되어 상태 관리 흐름 내에서 부작용이 깔끔하게 조율되도록 보장합니다. 이 패키지는 외부 상호작용과 함께 예측 가능한 상태 전환을 필요로 하는 복잡한 앱에 특히 유용합니다.
사용 사례
HTTP 요청 처리
내비게이션 흐름 관리
사용자 이벤트를 비동기적으로 처리
실시간 데이터 업데이트 구현
외부 서비스와 상태 동기화
주요 기능
스트림 기반의 부작용 처리
redux.dart와의 통합
선언적 효과 조합
테스트 가능하고 재사용 가능한 에픽
오류 처리 및 재시도 지원
적합한 대상
복잡한 상태 관리 필요
강한 비동기 로직을 가진 앱
기능 반응형 패턴을 선호하는 팀
redux.dart를 사용하는 프로젝트가 이미 존재합니다
명확한 관심사 분리를 추구하는 개발자
고려 사항
Streams 및 Rx에 대한 이해 필요
간단한 앱에 복잡성을 추가합니다
오류 처리 설계에 신중이 필요합니다
다른 대안들에 비해 문서가 제한적
초보자를 위한 학습 곡선이 더 가파릅니다
원본 README
연결된 소스 저장소의 README 원문 스냅샷입니다. 최신 내용은 저장소에서 확인하세요.
Redux Epics
Redux is great for synchronous
updates to a store in response to actions. However, working with complex
asynchronous operations, such as autocomplete search experiences, can be a bit
tricky with traditional middleware. This is where Epics come in!
The best part: Epics are based on Dart Streams. This makes routine tasks easy,
and complex tasks such as asynchronous error handling, cancellation, and
debouncing a breeze.
Note: For users unfamiliar with Streams, simple async cases are easier to handle
with a normal Middleware Function. If normal Middleware Functions or
Thunks work for you, you're
doing it right! When you find yourself dealing with more complex scenarios,
such as writing an Autocomplete UI, check out the Recipes below to see how
Streams / Epics can make your life easier.
Example
Let's say your app has a search box. When a user submits a search term, you
dispatch a PerformSearchAction which contains the term. In order to actually
listen for the PerformSearchAction and make a network request for the results,
we can create an Epic!
In this instance, our Epic will need to filter all incoming actions it receives
to only the Action it is interested in: the PerformSearchAction. This will
be done using the where method on Streams. Then, we need to make a network
request with the search term using asyncMap method. Finally, we need to
transform those results into an action that contains the search results. If an
error has occurred, we'll want to return an error action so our app can respond
accordingly.
Here's what the above description looks like in code.
import'dart:async';
import'package:redux_epics/redux_epics.dart';
Stream<dynamic> exampleEpic(Stream<dynamic> actions, EpicStore<State> store) {
return actions
.where((action) => action isPerformSearchAction)
.asyncMap((action) =>
// Pseudo api that returns a Future of SearchResults
api.search((action asPerformSearch).searchTerm)
.then((results) => SearchResultsAction(results))
.catchError((error) => SearchErrorAction(error)));
}
Connecting the Epic to the Redux Store
Now that we've got an epic to work with, we need to wire it up to our Redux
store so it can receive a stream of actions. In order to do this, we'll employ
the EpicMiddleware.
import'package:redux_epics/redux_epics.dart';
import'package:redux/redux.dart';
var epicMiddleware = newEpicMiddleware(exampleEpic);
var store = newStore<State>(fakeReducer, middleware: [epicMiddleware]);
Combining epics and normal middleware
To combine the epic Middleware and normal middleware, simply use both in the
list! Note: You may need to provide
var store = newStore<AppState>(
fakeReducer,
middleware: [myMiddleware, EpicMiddleware<AppState>(exampleEpic)],
);
If you're combining two Lists, please make sure to use the + or the ...
spread operator.
var store = newStore<AppState>(
fakeReducer,
middleware: [myMiddleware] + [EpicMiddleware<AppState>(exampleEpic)],
);
Combining Epics
Rather than having one massive Epic that handles every possible type of action,
it's best to break Epics down into smaller, more manageable and testable units.
This way we could have a searchEpic, a chatEpic, and an updateProfileEpic,
for example.
However, the EpicMiddleware accepts only one Epic. So what are we to do? Fear
not: redux_epics includes class for combining Epics together!
import'package:redux_epics/redux_epics.dart';
final epic = combineEpics<State>([
searchEpic,
chatEpic,
updateProfileEpic,
]);
Advanced Recipes
In order to perform more advanced operations, it's often helpful to use a
library such as RxDart.
Casting
In order to use this library effectively, you generally need filter down to
actions of a certain type, such as PerformSearchAction. In the previous
examples, you'll noticed that we need to filter using the where method on the
Stream, and then manually cast (action as SomeType) later on.
To more conveniently narrow down actions to those of a certain type, you have
two options:
TypedEpic
The first option is to use the built-in TypedEpic class. This will allow you
to write Epic functions that handle actions of a specific type, rather than all
actions!
final epic = newTypedEpic<State, PerformSearchAction>(searchEpic);
Stream<dynamic> searchEpic(
// Note: This epic only handles PerformSearchActionsStream<PerformSearchAction> actions,
EpicStore<State> store,
) {
return actions
.asyncMap((action) =>
// No need to cast the action to extract the search term!
api.search(action.searchTerm)
.then((results) => SearchResultsAction(results))
.catchError((error) => SearchErrorAction(error)));
}
RxDart
You can use the whereType method provided by RxDart. It will both perform a
where check and then cast the action for you.
import'package:redux_epics/redux_epics.dart';
import'package:rxdart/rxdart.dart';
Stream<dynamic> ofTypeEpic(Stream<dynamic> actions, EpicStore<State> store) {
// Wrap our actions Stream as an Observable. This will enhance the stream with// a bit of extra functionality.return actions
// Use `whereType` to narrow down to PerformSearchAction
.whereType<PerformSearchAction>()
.asyncMap((action) =>
// No need to cast the action to extract the search term!
api.search(action.searchTerm)
.then((results) => SearchResultsAction(results))
.catchError((error) => SearchErrorAction(error)));
}
Cancellation
In certain cases, you may need to cancel an asynchronous task. For example, your
app begins loading data in response to a user clicking on a the search button by
dispatching a PerformSearchAction, and then the user hit's the back button in
order to correct the search term. In that case, your app dispatches a
CancelSearchAction. We want our Epic to cancel the previous search in
response to the action. So how can we accomplish this?
This is where Observables really shine. In the following example, we'll employ
Observables from the RxDart library to beef up the power of streams a bit, using
the switchMap and takeUntil operator.
import'package:redux_epics/redux_epics.dart';
import'package:rxdart/rxdart.dart';
Stream<dynamic> cancelableSearchEpic(
Stream<dynamic> actions,
EpicStore<State> store,
) {
return actions
.whereType<PerformSearchAction>()
// Use SwitchMap. This will ensure if a new PerformSearchAction// is dispatched, the previous searchResults will be automatically // discarded.//// This prevents your app from showing stale results.
.switchMap((action) {
returnStream.fromFuture(api.search(action.searchTerm)
.then((results) => SearchResultsAction(results))
.catchError((error) => SearchErrorAction(error)))
// Use takeUntil. This will cancel the search in response to our// app dispatching a `CancelSearchAction`.
.takeUntil(actions.whereType<CancelSearchAction>());
});
}
Autocomplete using debounce
Let's take this one step further! Say we want to turn our previous example into
an Autocomplete Epic. In this case, every time the user types a letter into the
Text Input, we want to fetch and show the search results. Each time the user
types a letter, we'll dispatch a PerformSearchAction.
In order to prevent making too many API calls, which can cause unnecessary load
on your backend servers, we don't want to make an API call on every single
PerformSearchAction. Instead, we'll wait until the user pauses typing for a
short time before calling the backend API.
We'll achieve this using the debounce operator from RxDart.
import'package:redux_epics/redux_epics.dart';
import'package:rxdart/rxdart.dart';
Stream<dynamic> autocompleteEpic(
Stream<dynamic> actions,
EpicStore<State> store,
) {
return actions
.whereType<PerformSearchAction>()
// Using debounce will ensure we wait for the user to pause for // 150 milliseconds before making the API call
.debounce(newDuration(milliseconds: 150))
.switchMap((action) {
returnStream.fromFuture(api.search(action.searchTerm)
.then((results) => SearchResultsAction(results))
.catchError((error) => SearchErrorAction(error)))
.takeUntil(actions.whereType<CancelSearchAction>());
});
}
Dependency Injection
Dependencies can be injected manually with either a Functional or an Object-Oriented style. If you choose, you may use a Dependency Injection or Service locator library as well.
In production code the epics can be created at the point where combineEpics is called. If you're using separate main_<environment>.dart files to configure your application for different environments you may want to pass the config to the RealWebService at this point.