I've run into a situation where an idea I've come up with is referred to as an anti-pattern. I'm actually under the impression it's a best-practice and the current way of doing things is counterproductive.
This issue is in regards to Redux-Observable and its automatic dispatching of actions. It's been almost 2 years since I started using Redux-Observable. Over that time, I've solved out a lot of hard problems and gotten used to the crazy ways you have to write complex observables.
Some things I've done to improve the experience so far:
- Remove the need for Redux.
- Add better error logging.
- Break out long inner-observables into separate functions (usually adds unnecessary indirection).
Writing complex epics is pretty common in production applications. It was difficult to keep track of which actions were being dispatched when, especially to new people on the team, and difficult to follow what was going on when you kept going creating more and more inner observables.
I was recently on a project using Redux-Thunk and was astounded by how easy it is to dispatch actions. I get that most people started with it, but not me.
While I absolutely do not like the practice of making some action creators return promises, I do value the ability to dispatch as you need.
The main benefit? Your code is simpler, easier to maintain, and tabbed to the left. The further you tab code, the more closures you're dealing with and the harder it is to figure out what's being called where. This is also known as callback hell.
Observables are supposed to be a callback alternative, but without a way to dispatch, you end up with really long Christmas tree pipelines. I've also found that breaking apart large chunks of Redux-Observable epics into operators and observables adds unnecessary indirection.
I'm going to make the case there's a better way. Be warned though, this better way is considered an anti-pattern. I don't mean to rag on Jay Phelps or the other guys who work on Redux-Observable, I just wanna offer a strong opposing opinion to a pretty important architectural decision.
Removal of a Best-Practice?
I'm gonna go through an updated snippet from the PR removing access to Redux's store
.
This was something you might see in v0:
const someEpic = (
action$,
store,
) => (
action$
.pipe(
ofType(FETCH_DATA),
switchMap(() => (
ajax('/data')
.pipe(
tap(() => store.dispatch(doSomething()))
map(fetchSucceeded)
)
)
)
)
If you were writing epics like this example, you need to rethink your strategy. One action's being explicitly dispatched with store.dispatch
while the other is using Redux-Observable's automatic dispatch (dispatching anything that fell through the pipeline).
It's not a good pattern to have have two ways of doing things. To keep things simple for everyone, you need to have one way of doing thing.
Have you ever used AngularJS before? There were 3 different nearly-identical ways of managing data (service, factory, provider) and just about every article talked about them. Sadly, not one ever gave a good explanation of the "why". From the projects I was on, everyone agreed to use one method and that kept things simple.
When Redux-Observable went to v1, it also switched to having a single method of automatically dispatching actions:
const someEpic = (
action$,
) => (
action$
.pipe(
ofType(FETCH_DATA),
switchMap(() => (
ajax('/data')
.pipe(
switchMap((
response,
) => (
of(
doSomething(),
fetchSucceeded(response),
)
)),
)
)
)
)
These two examples are pretty simple. Listen to one action, output 2 things at the exact same time. Based on these examples, the change doesn't seem to be that big of a deal, but that's not how most epics look in the projects I've worked. If these were indicative of how most epics looked, this article wouldn't exist.
In losing store
, the alternative was state$
. This is definitely superior since you can directly subscribe to it. But we also lost dispatch
.
Lately, I've been thinking that maybe, the reliance on automatic dispatch could've been the anti-pattern.
Let's look at a definition of anti-pattern:
An anti-pattern is a common response to a recurring problem that is usually ineffective and risks being highly counterproductive.
The methods I'm proposing would add to productivity (in my opinion), rather than take away from it like automatic dispatching does. I'll make my case, but ultimately, you'll be the judge.
The Real Issue
When I started using Redux-Observable, store
was still available, but I was told v1 would change how it worked. Instead of learning the old method, I created my own API-compatible combineEpics
function using the new pattern and switched over to the official one when v1 released.
Because of this, I never knew what came before so I never really considered anything different from what I was told.
From teaching Redux-Observable to noobs, I've found it pretty difficult to explain the concept of automatically dispatching. Once you learn it, the concept is simple, but writing that code yourself? That's really tough. It's also one of the most commonly questioned things on Redux-Observable's Gitter.
In my opinion, Redux-Observable is the best wrapper RxJS, the killer app that makes it usable for complex applications by everyone. To understand automatic dispatch, you have to have a really good grasp of RxJS which is the opposite of what you want in a killer app.
When writing complex epics with automatic dispatch, you know how to pass multiple actions down a pipeline by splitting the pipeline into many smaller parallel pipelines. In most cases, you subscribe to one action$
pipeline, but you're splitting that into many other pipelines, each sending down their own actions that automatically get dispatched.
Here's an example what that looks like:
const fetchUserEpic = (
action$,
) => (
action$
.pipe(
ofType(FETCH_USER),
switchMap(({
password,
username,
}) => (
merge(
(
of(
setLoading(
'login',
)
)
),
(
ajax
.getJSON(
'https://api.example.com/login'
)
.pipe(
switchMap(({
authToken,
}) => (
of(
(
fetchUserSucceeded(
authToken
)
),
(
setLoaded(
'login',
)
),
)
)),
catchError((
error,
) => (
of(
fetchUserFailed(
error,
)
)
)),
)
),
)
)),
)
)
I know a lot of folks don't write code vertically, but I do. There are a lot of benefits to writing it like that, but that's outta scope for this article. I wanted to provide at least one example of what you'd see in a typical project:
const fetchUserEpic = action$ => (
action$.pipe(
ofType(FETCH_USER),
switchMap(({ username, password }) => (
merge(
of(setLoading('login')),
ajax.getJSON('https://api.example.com/login').pipe(
switchMap(({ authToken }) => of(
fetchUserSucceeded(authToken),
setLoaded('login'),
)),
catchError(error => of(fetchUserFailed(error))),
),
)
)),
)
)
Neither of these examples are very clear; especially not to an RxJS noob. I have no clue what's being dispatched (except I do << not most people), I don't understand why we have to switchMap
and merge
and what of
has to do with any of this even though it's used in 3 places.
If you called a dispatch
function, it'd be really easy to follow when a dispatch
occurs; in fact, you can reduce a lot of the inner observables too!
Let's change this around to use dispatch
instead:
const fetchUserEpic = (
action$,
state$,
{ dispatch },
) => (
action$
.pipe(
ofType(FETCH_USER),
tap(() => {
dispatch(
setLoading(
'login',
)
)
}),
switchMap(({
password,
username,
}) => (
ajax
.getJSON(
'https://api.example.com/login'
)
.pipe(
catchError((
error,
) => {
dispatch(
fetchUserFailed(
error,
)
)
return EMPTY
}),
)
)),
tap(({
authToken,
}) => {
dispatch(
fetchUserSucceeded(
authToken,
)
)
dispatch(
setLoaded(
'login',
)
)
}),
ignoreElements(),
)
)
What changed? We added Redux's dispatch
as a Redux-Observable dependency, added ignoreElements()
to the end, and tabbed everything back a bit. ignoreElements
is important because we don't want anything falling through the pipeline, especially if it's not a Redux action.
Instead of implicitly dispatching actions that fall through the pipeline, we're explicitly defining where a dispatch occurs and when it occurs. This reduced the epic down from 3 to 2 pipelines and our max tabbing went from 10 to 7 levels. That's a huge increase in readability right there!
We were also able to move fetchUserSucceeded
and setLoaded
outside the AJAX pipeline because there's no longer a chance of the output from catchError
piping through them.
Other than catchError
, which ended up slightly longer than before, the rest of our logic was reduced quite a bit. We could simplify this a bit more by creating our own catchError
wrapper:
const dispatchOnError = (
actionCreator,
returnValue,
) => (
catchError((
error,
) => {
dispatch(
actionCreator(
error,
)
)
return (
returnValue
? of(returnValue)
: EMPTY
)
})
)
In my experience, this is a pretty standard epic in frontend applications. I've written many crazier ones depending on the needs of the project and the number of inner observables gets unwieldy.
In past projects, the lack of a dispatch
function actually pushed me away from dispatching multiple actions from a single epic (one of the benefits of Redux-Observable) because it raises complexity and decreases readability.
Part of that comes from the creation of many inner observables, the other part is the uncertainty of dispatches. Sometimes you think an action's being dispatched and later find it's not. If I just called dispatch
directly, I'd never run into that situation. It's very deterministic.
Another Example
We should also take a look at the opposite example. Instead of listening to one observable and dispatching multiple actions, what's the effect when listening to multiple actions, and dispatching once?
const pingEpic = (
action$,
) => (
action$
.pipe(
ofType(CREATE_PING_LISTENER)
mergeMap(({
namespace,
}) => (
action$
.pipe(
ofType(PING),
ofNamespace(namespace),
takeUntil(
action$
.pipe(
ofType(STOP_PING_LISTENER)
)
),
switchMap(() => (
timer(1000)
)),
mapTo({ namespace }),
map(pong),
)),
)
)
In this example, the only thing that changes is map(pong)
. Now it'll look like this:
tap(() => {
dispatch(
pong(),
)
}),
ignoreElements(),
Not as clean. I'm using this example to illustrate that dispatch
isn't always the cleanest option, but at least it doesn't make things significantly worse.
You might think that wrapping functions in dispatch
like you'd see in React-Redux's mapToDispatch
argument would clean this up. I'd argue the opposite. All that function ever did was make it harder to figure out what was going on. It's no surprise it was removed it in the hooks version.
The Other Proposal
Epic Arguments
Right now, epics take 3 arguments, action$
, state$
, and dependencies
. If you're passing in multiple arguments, I prefer those as a single object instead:
const someEpic = ({
action$,
dispatch,
}) => (
// ...
)
With objects, you don't have to know the order so you can deconstruct only what you need. They're somewhat harder to memoize, but you probably shouldn't be memoizing your epics. Also, dependencies
is an object, so I don't see why action$
and state$
have to be excluded from it.
If epics take in a single object, we could dynamically change the Redux-Observable API without requiring a complete rewrite of all epics. For instance, if the old version of Redux-Observable passed store
in an object, you wouldn't have had to change everything up-front when state$
came around.
The way it is now with multiple arguments, changing the epic API is a lot harder; thus, adding dispatch
requires grabbing an object off the 3rd argument.
Never Automatically Dispatch
If dispatch
was part of Redux-Observable, automatic dispatching should be both discouraged and removed completely. Having two ways of doing the same thing makes the code much harder to maintain over time.
While it might be difficult to do today, you can always pipe off your rootEpic
and add ignoreElements()
to it without changing any other code.
Of course, this would cause quite a few problems, so maybe a solution like this would be better:
const epic$ = (
new BehaviorSubject(
rootEpic
.pipe(
switchMap((
value,
) => (
typeof value === 'object'
&& value.type
? of(value)
: EMPTY
))
)
)
)
With this code, you could integrate with your current codebase as-is and add dispatch
to epics over time.
Working Example
For a working example with dispatch
as a dependency, you can use this CodeSandbox:
https://codesandbox.io/s/redux-observable-middleware-with-dispatch-0s09v
The Expert Hypothesis
Shouldn't everyone using Redux-Observable be an RxJS expert? No. This library is so wonderful, I'd hate for it to be limited to people and teams that know their stuff. Why make it harder than it needs to be?
Take this quote from the Redux-Observable docs:
Redux-observable (because of RxJS) truly shines the most for complex async/side effects. If you're not already comfortable with RxJS you might consider using redux-thunk for simple side effects and then use redux-observable for the complex stuff. That way you can remain productive and learn RxJS as you go. redux-thunk is much simpler to learn and use, but that also means it's far less powerful. Of course, if you already love Rx like we do, you will probably use it for everything!
In my opinion, Redux-Observable replaces Redux-Thunk. I've even written entire applications with Redux-Observable and nothing else.
By this logic, Redux-Observable is both powerful but also complex. What if we could significantly reduce complexity and keep the high level of power? Would you even need to suggest Redux-Thunk?
Conclusion
I really love how Redux-Observable has improved my ability to write asynchronous modules. It's surprising to think I stumbled upon an implementation of message-oriented programming without realizing it.
After almost 2 years of use, doing a talk, writing many articles, and using it on many production projects, I've come to a different conclusion on the real anti-pattern.
It's possible I'm completely wrong. Maybe this idea isn't so great at all, and I'm not seeing something important. If that's the case, I'd love to get some hard feedback and criticism. Do you like this idea or do you know something I don't?
I definitely don't know the strange situations people got into before dispatch
was removed, but I do know its removal significantly increased the complexity of many already-complex epics. That's what I want to solve and the best way I know is by putting an idea out there and seeing if I get any reasonable arguments against it.
More Reads
If you liked what you read, please checkout my other articles on similar eye-opening topics:
Top comments (0)