-
-
Save jayphelps/0e92f3655032f9a055c475ae1d43c06b to your computer and use it in GitHub Desktop.
Making abstractions for redux and redux-observable
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// WARNING: Completely untested code. it might not work and/or it might have | |
// things that don't work well. Just made for illustrational purposes | |
// redux-observable shines the most with complex async stuff, like WebSockets | |
// but many of us will still use it for more modest things like AJAX requests. | |
// In these cases, there can be a ton of repetitive boilerplate. So this is a | |
// simple example of applying some abstractions and conventions to make it easier. | |
// THAT SAID, since abstractions cause indirection it can make it harder for | |
// someone to come along later and know how something works. Weigh the costs | |
// and remember, this example isn't a suggestion of the actual code you should | |
// be using :o) | |
// Say this is the pattern you use all the time: | |
const FETCH_USER = 'FETCH_USER'; | |
const FETCH_USER_FULFILLED = 'FETCH_USER_FULFILLED'; | |
const FETCH_USER_REJECTED = 'FETCH_USER_REJECTED'; | |
const FETCH_USER_CANCELLED = 'FETCH_USER_CANCELLED'; | |
const fetchUser = id => ({ type: FETCH_USER, id }); | |
const fetchUserCancelled = id => ({ type: FETCH_USER_CANCELLED, id }); | |
const fetchUserFulfilled = response => ({ type: FETCH_USER_FULFILLED, response }); | |
const fetchUserRejected = error => ({ type: FETCH_USER_REJECTED, error }); | |
const fetchUserEpic = (action$, store) => | |
action$.ofType(FETCH_USER) | |
.mergeMap(action => | |
ajax(`/api/users/${action.payload}`) | |
.map(response => fetchUserFulfilled(response)) | |
.catch(error => Observable.of( | |
fetchUserRejected(error) | |
)) | |
.takeUntil(action$.ofType(FETCH_USER_CANCELLED)) | |
); | |
const users = (state = {}, action) => { | |
switch (action.type) { | |
case FETCH_USER: | |
return { | |
...state, | |
[action.id]: { | |
...state[action.id], | |
isLoading: true, | |
error: null | |
} | |
}; | |
case FETCH_USER_CANCELLED: | |
return { | |
...state, | |
[action.id]: { | |
...state[action.id], | |
isLoading: false | |
} | |
}; | |
case FETCH_USER_FULFILLED: | |
return { | |
...state, | |
[action.id]: { | |
isLoading: false, | |
error: null, | |
payload: action.payload | |
} | |
}; | |
case FETCH_USER_REJECTED: | |
return { | |
...state, | |
[action.id]: { | |
isLoading: false, | |
error: action.error, | |
payload: null | |
} | |
}; | |
} | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Epics and reducers are just functions, so we can abstract | |
// all the things with a factory. We can use convention too | |
// if that's your bag. YMMV | |
const createFetchHandler = ({ name, url: urlTemplate, concurrency = 'merge' }) => { | |
const FETCH = `FETCH_${name}`; | |
const FETCH_CANCELLED = `FETCH_${name}_CANCELLED`; | |
const FETCH_FULFILLED = `FETCH_${name}_FULFILLED`; | |
const FETCH_REJECTED = `FETCH_${name}_REJECTED`; | |
const fetch = id => ({ type: FETCH, id }); | |
const cancel = id => ({ type: FETCH_CANCELLED, id }); | |
const fulfill = (id, response) => ({ type: FETCH_FULFILLED, id, response }); | |
const reject = (id, error) => ({ type: FETCH_REJECTED, id, error }); | |
// e.g. mergeMap, switchMap, concatMap, etc | |
const concurrencyOperator = `${concurrency}Map`; | |
const epic = (action$, store) => | |
action$.ofType(FETCH) | |
[concurrencyOperator](action => { | |
// allows things like `/api/users/:id` where `id` will get looked up | |
const url = urlTemplate.replace(/:([a-zA-Z]+)/g, (match, key) => action[key]); | |
return ajax(url) | |
.map(response => fulfill(action.id, response)) | |
.catch(error => Observable.of( | |
reject(id, error) | |
)) | |
.takeUntil(action$.ofType(FETCH_CANCELLED)) | |
}); | |
const reducer = (state = {}, action) => { | |
switch (action.type) { | |
case FETCH: | |
return { | |
...state, | |
[action.id]: { | |
...state[action.id], | |
isLoading: true, | |
error: null | |
} | |
}; | |
case FETCH_CANCELLED: | |
return { | |
...state, | |
[action.id]: { | |
...state[action.id], | |
isLoading: false | |
} | |
}; | |
case FETCH_FULFILLED: | |
return { | |
...state, | |
[action.id]: { | |
isLoading: false, | |
error: null, | |
payload: action.payload | |
} | |
}; | |
case FETCH_REJECTED: | |
return { | |
...state, | |
[action.id]: { | |
isLoading: false, | |
error: action.error, | |
payload: null | |
} | |
}; | |
default: | |
return state; | |
} | |
}; | |
return { epic, reducer, fetch, cancel, fulfill, reject }; | |
}; | |
export const { epic, reducer, fetch, cancel } = createFetchHandler({ | |
name: 'USER', | |
url: '/api/users/:id', | |
concurrency: 'merge' // or 'switch', 'concat', etc | |
}); | |
/* Setting up redux middleware using it is straight forward */ | |
import * as users from './users'; | |
import * as todos from './todos'; | |
const rootReducer = combineReducers({ | |
users: users.reducer, | |
todos: todos.reducer | |
// ...etc | |
}); | |
const rootEpic = combineEpics( | |
users.epic, | |
todos.epic | |
// ...etc | |
); | |
/* Then we can use it in our UI later like this: */ | |
store.dispatch(users.fetch(123)); | |
// etc | |
store.dispatch(users.cancel(123)); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
How do I put an extra callback for success or failure from outside, which can also dispatch actions?