Last active
July 1, 2016 18:09
-
-
Save oscarduignan/c79aab79b3ff69b52ee7 to your computer and use it in GitHub Desktop.
Outline of pattern for building stuff with RxJS and React. Originally at http://jsbin.com/jelale/edit?js and mirrored here to make it clearer that it's not currently supposed to be executable!
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
/* | |
OUTLINE FOR AN APP BUILT WITH RXJS AND REACT, USING AN ELASTICSEARCH FACETED SEARCH | |
MODULE AS AN EXAMPLE, READ FROM BOTTOM UP IF YOU WANT TO GO OUTSIDE IN, START FROM | |
TOP TO SEE HOW THE SEARCH MODULE IS COMPOSED. | |
If you find this I would love to hear some feedback - it's not designed to work without | |
any modification though, it's just supposed to outline the architecture off-the-top-of- | |
my-head-pretty-close-to-working psuedocode of something that you might actually need to | |
build to drive out if the pattern is any good! | |
*/ | |
// search/intents.js | |
export changeQuery = new Rx.Subject(); | |
export toggleTag = new Rx.Subject(); | |
export changePage = new Rx.Subject(); | |
// rx-utils.js | |
export combineLatestAsStruct(keysAndStreams) { | |
var keys = Object.keys(keysAndStreams); | |
var streams = keys.map(key => keysAndStreams[key]); | |
return Rx.Observable.combineLatest(...streams, (...args) => { | |
return mapValues(keysAndStreams, (value, key) => { | |
return args[keys.indexOf(key)]; | |
}); | |
}); | |
}; | |
// search/api.js | |
export search({query, selectedTags, currentPage}) {/* return a promise or something */}; | |
// search/model.js | |
// import { combineLatestAsStruct } from 'rx-utils'; | |
// import { search } from 'api'; | |
// import { changeQuery, toggleTag, changePage } from 'intents'; | |
query = new Rx.BehaviorSubject(); | |
changeQuery. | |
subscribe(query); | |
selectedTags = new Rx.BehaviorSubject(); | |
toggleTag. | |
subscribe(tag => { | |
// selectedTags.onNext(append or splice the tag to or from selectedTags depending on if it's present) | |
}); | |
resultsFrom = new Rx.BehaviorSubject(); | |
resultsPerPage = new Rx.BehaviorSubject(); | |
changePage. | |
withLatestFrom(resultsPerPage, (page, perPage) { | |
return (page * perPage) - perPage; | |
}). | |
subscribe(resultsFrom); | |
searches = combineLatestAsStruct({ | |
query, | |
selectedTags, | |
currentPage | |
}); | |
responses = searches. | |
debounce(200). | |
flatMapLatest(search). | |
share(); | |
results = responses. | |
pluck('hits', 'hits'). | |
map(hits => { | |
// assuming that we are using elasticsearch, lets hide that from our views | |
return hits.map(hit => hit._source); | |
}); | |
possibleTags = responses. | |
pluck('aggregations', 'tags', 'buckets'); | |
// so with elasticsearch facets via aggs from this you get an array of | |
// objects with a key and doc_count, with key being the tag slug. | |
totalResults = responses. | |
pluck('hits', 'total'); | |
totalPages = totalResults. | |
withLatestFrom(resultsPerPage, (total, perPage) => { | |
return Math.ceil(total / perPage); | |
}); | |
currentPage = resultsFrom. | |
withLatestFrom(resultsPerPage, (from, perPage) => { | |
return Math.ceil((from / perPage) + 1); // from is 0 indexed with elasticsearch | |
}); | |
export state = combineLatestAsStruct({ | |
query, | |
selectedTags, | |
possibleTags, | |
results, | |
totalPages, | |
currentPage | |
}); | |
// search/views/SearchForm.jsx (assume this is our only root view of the search module) | |
// import { changeQuery, toggleTag, changePage } from '../intents'; | |
export SearchForm = React.createClass({ | |
propTypes: { | |
query: React.propTypes.string, | |
selectedTags: React.propTypes.array, | |
possibleTags: React.propTypes.array, | |
results: React.propTypes.array, | |
totalPages: React.propTypes.number, | |
currentPage: React.propTypes.number | |
}, | |
render() { | |
return ( | |
<div> | |
<h2>Search form</h2> | |
<input type='text' value={this.props.query} onChange={changeQuery.onNext} /> | |
<h2>Search results</h2> | |
<SearchFilters {..this.props} toggleTag={toggleTag.onNext} /> | |
<SearchResults {..this.props} /> | |
<Pagination {..this.props} changePage={changePage.onNext} /> | |
</div> | |
); | |
} | |
}); | |
// search/views/SearchResults.jsx | |
export SearchResults = React.createClass({ | |
propTypes: { | |
results: React.propTypes.array.isRequired | |
}, | |
render() { | |
return ( | |
<ul> | |
{this.props.results.map(result => { | |
return <li><a href={result.url}>{result.title}</a></li>; // MVP assumes results are just titles and links | |
})} | |
</ul> | |
); | |
} | |
}); | |
// search/views/SearchFilters.jsx | |
export SearchFilters = React.createClass({ | |
propTypes: { | |
selectedTags: React.propTypes.array.isRequired, | |
possibleTags: React.propTypes.array.isRequired, | |
toggleTag: React.propTypes.func.isRequired | |
}, | |
render() { | |
var { selectedTags, possibleTags, toggleTag } = this.props; | |
return ( | |
<fieldset> | |
<legend>Tags</legend> | |
<ul> | |
{possibleTags.map(tag => { | |
return <li><input type="checkbox" onClick={toggleTag} checked={tag in selectedTags} /> {tag}</li>; | |
})} | |
</ul> | |
</fieldset> | |
); | |
} | |
}); | |
// search/views/Pagination.jsx (ripe for reuse) | |
export Pagination = React.createClass({ | |
propTypes: { | |
totalPages: React.propTypes.number.isRequired, | |
currentPage: React.propTypes.number.isRequired, | |
changePage: React.propTypes.func.isRequired | |
}, | |
render() { | |
var { totalPages, currentPage } = this.props; | |
return ( | |
<li> | |
// prev page | |
{(currentPage > 1) ? <PageLink {..this.props} page={currentPage-1} /> : false} | |
// [1..totalPages].map(page => <PageLink {..this.props} page={page} />) | |
// next page | |
{(currentPage < totalPages) ? <PageLink {..this.props} page={currentPage+1] /> : false} | |
</li> | |
); | |
} | |
}); | |
export PageLink = React.createClass({ | |
propTypes: { | |
page: React.propTypes.number.isRequired, | |
currentPage: React.propTypes.number.isRequired, | |
changePage: React.propTypes.func.isRequired | |
}, | |
render() { | |
return <li><button onClick={changePage} disabled={page === currentPage}> {page}</button></li>; | |
} | |
}); | |
// App.jsx (where you pull together your modules, guess this would be your router) | |
// import { combineLatestAsStruct } from 'rx-utils'; | |
// import { state as searchState } from 'search/model'; | |
// import { SearchForm } from 'search/views/SearchForm'; | |
// ... import other modules state and views | |
export state = combineLatestAsStruct( | |
search: searchState, | |
// ... other general state / module state | |
); | |
// TODO be interested to see if there were any problems using a router | |
// like react-router with this kind of pattern for react apps. Or any | |
// issues prerendering your app on the server - guessing not, and that | |
// this would probably suite isomorphic apps really well, only bit I | |
// wouldn't be sure about would be the intents imported into some views | |
// that isn't being passed down through props from the top. | |
export App = React.createComponent({ | |
propTypes: { | |
search: React.propTypes.object | |
}, | |
render() { | |
return ( | |
<div> | |
<h1>Your React and RxJS App</h1> | |
<SearchModule {..this.props.search} /> | |
</div> | |
); | |
} | |
}); | |
// then in some main() or some entrypoint somewhere just have the below | |
// (or you could just put it inside App.jsx for simplicity if you want!) | |
// import { state, App } from 'App.jsx'; | |
// say for example you want to grab default state from URL and persist | |
// it back there on change, here is where you could do that, you would | |
// just expose and subscribe to the relevant stuff in the search/model. | |
// if you want an example of this behavior then bodge me / or hunt in | |
// github.com/oscarduignan/react-rxjs-elasticsearch-faceted-search-example | |
state.subscribe(state => { | |
React.render(<App {..state} />); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment