iTranslated by AI
Experience the Power of RxJS by Implementing a Search Feature with redux-observable
Introduction
I felt that the article TAKING ADVANTAGE OF OBSERVABLES IN ANGULAR would deepen my understanding of Rx, so I wanted to use it as a base to explore further (hereafter referred to as the original article).
In this post, the goal is to get familiar with RxJS by implementing the content of the original article using React + Redux + redux-observable.
My Current Status with redux-observable and RxJS
- I've started to appreciate redux-observable as middleware for asynchronous processing in Redux.
- The API is very simple. It makes actionCreators cleaner.
- It simplifies doing multiple things when a single action is fired or performing common processing for multiple types of actions.
- The philosophy of not letting an action be modified once it's fired is quite easy to handle and feels right.
- The API is very simple. It makes actionCreators cleaner.
- But I'm an "I don't get Rx" person.
- I can use Rx just enough to resolve asynchronicity for redux-observable.
- I'm in a state where I can say, "Oh, 'Everything is a Stream,' right? Like what that Jack Russell Terrier (Rob Wormald) says. Yeah, I know that."
- I haven't quite grasped how Rx benefits Redux's unidirectional data structure.
Summary of the Original Article
- TAKING ADVANTAGE OF OBSERVABLES IN ANGULAR
- Consider building a feature that searches and displays results immediately as you type into an Input field.
- In this scenario, you face three major problems:
- It's not ideal to send a request for every single keystroke.
- It's not ideal to send redundant requests when the same search term would suffice.
- For example, if the input goes
foo -> fooo -> foo -> fooo -> foo, unnecessary data might be sent.
- For example, if the input goes
- Handling multiple fired requests.
- If requests are fired in the order A -> B, but return in the order B -> A, the display might get messed up.
- This is a sample article dealing with Angular.
Implementation
Preparation
api.js
Since the original article uses Wikipedia's opensearch, I'll implement something similar.
I'll keep the API layer independent and make it return a Promise.
import fetchJsonp from 'fetch-jsonp'
import qs from 'querystring'
export const searchApi = (word) => {
const baseURL = "https://ja.wikipedia.org/w/api.php"
const params = {
action: "opensearch",
format: "json",
search: word
}
const url = `${baseURL}?${qs.stringify(params)}`
return fetchJsonp(url)
.then( response => response.json() )
.then( json => json[1])
}
action / reducer
The common parts of Redux are created straightforwardly. There are just two actions: changeInput and loadResult.
// action.js
export const CHANGE_INPUT = "CHANGE_INPUT"
export const LOAD_RESULT = "LOAD_RESULT"
// When key input changes
export const changeInput = (input) => ({
type: CHANGE_INPUT,
payload: input
})
// When API results are received
export const loadResult = (result) => ({
type: LOAD_RESULT,
payload: result
})
// reducer.js
import { LOAD_RESULT, CHANGE_INPUT } from './actions'
const result = (state = [], action ) => {
switch(action.type){
case LOAD_RESULT:
return action.payload
default:
return state
}
}
const word = (state = "", action ) => {
switch(action.type){
case CHANGE_INPUT:
return action.payload
default:
return state
}
}
export const reducers = { result, word }
component / container
This also just simply handles the display and hooks up the events.
import React from 'react'
import { Provider } from 'react-redux'
import { connect } from 'react-redux'
import { configureStore } from './store'
import { changeInput } from './actions'
const SearchPedia = ( { word, result, changeInput }) => {
return <div>
<input value={ word } onChange={ e => changeInput(e.target.value)} />
<h3>Result</h3>
<ul>
{result.map( (r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
}
// Container
const SearchPediaContainer = connect(
state => state,
{ changeInput }
)(SearchPedia)
// Provider
export default class App extends React.Component{
constructor(){
super()
this.store = configureStore()
}
render(){
return (
<Provider store={this.store} >
<SearchPediaContainer />
</Provider>
)
}
}
Part 1. Promise-based implementation using middleware
First, let's try implementing it without using RxJS / redux-observable.
To provide a contrast with Observables, I'll use a plain middleware instead of Thunk or Promise middleware.
// middleware.js
import { CHANGE_INPUT, loadResult } from './actions'
import { searchApi } from './api'
// Usually, you wouldn't write implementation details directly in middleware, but I'll do it this way for explanation purposes.
export const searchMiddleware = store => next => action => {
if(action.type === CHANGE_INPUT){
// Trigger separate search processing only for `CHANGE_INPUT`
searchApi(action.payload)
.then( result => {
store.dispatch(loadResult(result))
})
}
return next(action)
}
// store.js
import { createStore, combineReducers, applyMiddleware } from "redux"
import { reducers } from './reducers'
export const configureStore = () => {
return createStore(
combineReducers(reducers, {}),
applyMiddleware(searchMiddleware)
)
}
Resulting Product
You can see that communication is happening for every input. Resolving the three problems mentioned in the original article would likely be quite difficult using this approach.
Part 2. Implementing with redux-observable
Finally, the main topic.
Setting up the redux-observable middleware.
// store.js
import { createStore, combineReducers, applyMiddleware } from "redux"
import { createEpicMiddleware } from "redux-observable"
import { reducers } from './reducers'
import { epics } from './epic'
export const configureStore = () => {
return createStore(
combineReducers(reducers, {}),
applyMiddleware(createEpicMiddleware(epics))
)
}
// epic.js
import 'rxjs'
import { CHANGE_INPUT, loadResult } from './actions'
import { combineEpics } from 'redux-observable'
import { searchApi } from './api'
// For now, create the base epic. Returning (action$) => action$ creates an infinite loop,
// so we use ignoreElements to do nothing for now.
const searchEpic = (action$) => action$.ofType(CHANGE_INPUT)
.do(action => console.log(action)) // For debugging
.ignoreElements()
export const epics = combineEpics( searchEpic)
I referred to this Stackoverflow post for the debugging with do and ignoreElements.
From here, I'll gradually extend the epic's observable.
Phase 1: Create an epic that returns dummy data when CHANGE_INPUT arrives
For now, I'll create something that returns a mock. I'm using the do operator for console.log debugging.
const searchEpic = (action$) => (
action$.ofType(CHANGE_INPUT)
.do(action => console.log(action)) // For debugging
.mapTo(loadResult([ "DUMMY", "DUMMY", "DUMMY"])) // mapTo returns a value regardless of the input
.do(action => console.log(action)) // For debugging
)
With this, actions will flow like this:
Object {type: "CHANGE_INPUT", payload: "a"}
Object {type: "LOAD_RESULT", payload: Array[3]}
Object {type: "CHANGE_INPUT", payload: "ab"}
Object {type: "LOAD_RESULT", payload: Array[3]}
Object {type: "CHANGE_INPUT", payload: "abc"}
Object {type: "LOAD_RESULT", payload: Array[3]}
Phase 2: Resolve Promise using mergeMap (formerly flatMap)
By returning a promise within mergeMap, we can obtain the result of the promise processing. At this stage, we've achieved the same communication capability as the middleware implementation.
const searchEpic = (action$) => (
action$.ofType(CHANGE_INPUT)
.do(action => console.log(action)) // For debugging
.map( ({payload}) => payload )
.mergeMap( (word) => searchApi(word) ) // Resolve Promise
.map( result => loadResult(result) ) // Return result
.do(action => console.log(action)) // For debugging
)
You can see that a request is issued every time a key is pressed.
Object {type: "CHANGE_INPUT", payload: "a"}
"CALL API" "a"
Object {type: "LOAD_RESULT", payload: Array[10]}
Object {type: "CHANGE_INPUT", payload: "ab"}
"CALL API" "ab"
Object {type: "LOAD_RESULT", payload: Array[10]}
Object {type: "CHANGE_INPUT", payload: "abc"}
"CALL API" "abc"
Object {type: "LOAD_RESULT", payload: Array[10]}
Phase 3: Consolidate continuous input with debounceTime (Original article: Taming the user input)
We use debounceTime to prevent the API from running every time a continuous input like a, ab, abc occurs. Now it's starting to feel like RxJS.
const searchEpic3 = (action$) => (
action$.ofType(CHANGE_INPUT)
.do(action => console.log(action)) // For debugging
.map( ({payload}) => payload )
.debounceTime(400) // Discard inputs occurring within 400ms
.mergeMap( (payload) => searchApi(payload) )
.map( result => loadResult(result) )
.do(action => console.log(action)) // For debugging
)
When running this, you can see that, unlike before, the API call only happens at the timing when key input stops.
Object {type: "CHANGE_INPUT", payload: "a"}
Object {type: "CHANGE_INPUT", payload: "ab"}
Object {type: "CHANGE_INPUT", payload: "abc"}
"CALL API" "abc"
Object {type: "LOAD_RESULT", payload: Array[10]}
Phase 4: Communicate only when the value changes with distinctUntilChanged (Original article: Don’t hit me twice)
Control unnecessary requests for the same value.
const searchEpic = (action$) => (
action$.ofType(CHANGE_INPUT)
.do(action => console.log(action)) // For debugging
.map( ({payload}) => payload )
.debounceTime(400)
.distinctUntilChanged() // Process only when the value has changed.
.mergeMap( (payload) => searchApi(payload) )
.map( result => loadResult(result) )
.do(action => console.log(action)) // For debugging
)
In this way, if you input "a -> ab -> abc (wait here) -> ab -> abc", you can see that the API only runs once.
Object {type: "CHANGE_INPUT", payload: "a"}
Object {type: "CHANGE_INPUT", payload: "ab"}
Object {type: "CHANGE_INPUT", payload: "abc"}
"CALL API" "abc"
Object {type: "LOAD_RESULT", payload: Array[10]}
Object {type: "CHANGE_INPUT", payload: "ab"}
Object {type: "CHANGE_INPUT", payload: "abc"}
// API is not issued here
By the way, since distinctUntilChanged performs a value comparison, it won't work correctly if you pass the action as-is, as shown below:
const searchEpic = (action$) => (
action$.ofType(CHANGE_INPUT)
.distinctUntilChanged() // Comparison of currentAction === previewAction won't work well
)
In this sample, I converted it to a string comparison beforehand using map( ({payload}) => payload). If you really need to compare by action, using something like deep-equal seems like a good idea.
Phase 5: mergeMap -> switchMap (Original article: Dealing with out-of-order responses)
Changing from mergeMap to switchMap apparently means "unsubscribing from the previous subscription the moment a new value arrives."
Using this feature, it seems that if two API requests are issued in succession, the one that came later will be prioritized for processing.
const searchEpic = (action$) => (
action$.ofType(CHANGE_INPUT)
.do(action => console.log(action)) // For debugging
.map( ({payload}) => payload )
.debounceTime(400)
.distinctUntilChanged()
.switchMap( (payload) => searchApi(payload) ) // Change to switchMap
.map( result => loadResult(result) )
.do(action => console.log(action)) // For debugging
)
To be honest, I feel my understanding here is still shallow, but I understand it to mean exactly that by looking at the marble diagrams for mergeMap and switchMap.
The following might also be helpful:
- learn-rxjs
- SwitchMap vs MergeMap in the #ngrx example
-
Understanding RxJava's flatMap, switchMap, and concatMap
- A metaphor that was personally very easy to understand.
- Although it's RxJava instead of RxJS, or flatMap instead of mergeMap, it can be read interchangeably.
- What is the difference between flatmap and switchmap in RxJava?
Finished Product & Final Form
Finally, the epic ended up looking like this (deleting the do debugging that was written halfway through):
// epic.js
import 'rxjs'
import { CHANGE_INPUT, loadResult } from './actions'
import { combineEpics } from 'redux-observable'
import { searchApi } from './api'
const searchEpic = (action$) => (
action$.ofType(CHANGE_INPUT)
.map( ({payload}) => payload )
.debounceTime(400)
.distinctUntilChanged()
.switchMap( (payload) => searchApi(payload) )
.map( result => loadResult(result) )
)
export const epics = combineEpics(
searchEpic,
)
Summary
- I feel like I've somewhat grasped the "heart" of RxJS.
- I'm starting to be able to read what the marble diagrams are intending to show.
- It's hard to understand if you're just using redux-observable for asynchronous processing, but I felt that getting your hands dirty and trying things out really helps with understanding.
- Trying to force yourself to use operators where you think, "Wait, what's the benefit of this?" seems like a good way to learn a lot.
- I've started to see how to leverage Rx in Redux.
- In this example, without Rx, you would likely need to hold various variables in the Redux store or temporarily store them in the component's local state.
- It can be considered a way to resolve data processing when you "don't want to store it as Redux data."
- Based on the world of Redux, it might lead to better understanding if you think of it roughly as "having an independent state inside Rx."
- Using
doandignoreElementsduring debugging is subtly very convenient. Learning about this was a personal win. - It's amazing how information on Angular or RxJava can be applied with almost no discrepancy. The Rx family is strong.
Discussion