Skip to content

Instantly share code, notes, and snippets.

@oscarduignan
Last active July 1, 2016 18:09
Show Gist options
  • Save oscarduignan/c79aab79b3ff69b52ee7 to your computer and use it in GitHub Desktop.
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!
/*
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