├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── home.html ├── index.html ├── package.json ├── server.js ├── src ├── api.js ├── bicycle-schema │ └── objects │ │ ├── root.js │ │ └── story.js ├── bicycle │ ├── components │ │ ├── app.js │ │ └── story.js │ └── index.js ├── data-store │ ├── components │ │ ├── app.js │ │ ├── spinner.js │ │ ├── story.js │ │ └── with-data.js │ ├── data-store.js │ └── index.js ├── index.js ├── raw │ ├── components │ │ ├── app.js │ │ ├── spinner.js │ │ └── story.js │ └── index.js └── with-data │ ├── components │ ├── app.js │ ├── spinner.js │ ├── story.js │ └── with-data.js │ └── index.js └── test └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["forbeslindesay"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | server.js 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'forbeslindesay', 3 | rules: { 4 | 'no-unused-vars': [0], 5 | 'no-extra-semi': [0], 6 | 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Coverage directory used by tools like istanbul 11 | coverage 12 | 13 | # Compiled binary addons (http://nodejs.org/api/addons.html) 14 | build/Release 15 | 16 | # Dependency directory 17 | node_modules 18 | 19 | # Users Environment Variables 20 | .lock-wscript 21 | 22 | # Babel build output 23 | /lib 24 | 25 | # Config files 26 | environment.toml 27 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | sudo: false 4 | 5 | node_js: 6 | - "6.2.1" 7 | 8 | deploy: 9 | provider: script 10 | script: npm run deploy 11 | on: 12 | branch: master 13 | node_js: 6.2.1 14 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2016 [Forbes Lindesay](https://github.com/ForbesLindesay) 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | > SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-data-fetching-demo 2 | 3 | A demo of different ways of doing data fetching in react 4 | 5 | [![Build Status](https://img.shields.io/travis/ForbesLindesay/react-data-fetching-demo/master.svg)](https://travis-ci.org/ForbesLindesay/react-data-fetching-demo) 6 | [![Dependency Status](https://img.shields.io/david/ForbesLindesay/react-data-fetching-demo/master.svg)](http://david-dm.org/ForbesLindesay/react-data-fetching-demo) 7 | 8 | The three different methods demonstrated are: 9 | 10 | 1. raw - a raw react implementation 11 | 2. with-data - an implementation using a `withData` higher order component 12 | 3. data-store - an implementation using a centralised data store 13 | 4. bicycle - an implementation using the bicycle data fetching library 14 | 15 | You can view the live demo at https://react-data-fetching-demo.forbeslindesay.co.uk/ 16 | 17 | ## License 18 | 19 | MIT 20 | -------------------------------------------------------------------------------- /home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Data Fetching Demo 6 | 7 | 12 | 13 | 14 |
    15 |
  1. raw - a raw react implementation
  2. 16 |
  3. with-data - an implementation using a withData higher order component
  4. 17 |
  5. data-store - an implementation using a centralised data store
  6. 18 |
  7. bicycle - an implementation using the bicycle data fetching library
  8. 19 |
20 |

21 | Source code on GitHub 22 |

23 | 24 | 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Data Fetching Demo 6 | 7 | 8 | 65 | 66 | 67 |
68 |
69 |

70 | Change Demo 71 |

72 |

73 | Source code on GitHub 74 |

75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-data-fetching-demo", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "A demo of different ways of doing data fetching in react", 6 | "dependencies": { 7 | "bicycle": "0.1.5", 8 | "body-parser": "^1.15.1", 9 | "browserify-middleware": "^7.0.0", 10 | "envify": "^3.4.0", 11 | "express": "^4.13.4", 12 | "prepare-response": "^1.1.3", 13 | "promise": "^7.1.1", 14 | "react": "^15.1.0", 15 | "react-bicycle": "^2.0.0", 16 | "react-dom": "^15.1.0", 17 | "then-mongo": "^2.3.2", 18 | "then-request": "^2.2.0", 19 | "uglifyify": "^3.0.2" 20 | }, 21 | "devDependencies": { 22 | "babel-cli": "^6.4.0", 23 | "babel-preset-forbeslindesay": "^1.0.0", 24 | "babel-register": "^6.9.0", 25 | "babelify": "^7.3.0", 26 | "eslint": "^2.11.1", 27 | "eslint-config-forbeslindesay": "^1.3.0", 28 | "estraverse-fb": "^1.3.1", 29 | "testit": "^2.0.2" 30 | }, 31 | "scripts": { 32 | "build": "babel src --out-dir lib", 33 | "lint": "eslint src", 34 | "test": "babel-node test/index.js && npm run lint", 35 | "predeploy": "npm install && npm run build && npm prune --prod", 36 | "deploy": "npm i heroku-release && heroku-release --app react-data-fetching-demo" 37 | }, 38 | "repository": { 39 | "type": "git", 40 | "url": "https://github.com/ForbesLindesay/react-data-fetching-demo.git" 41 | }, 42 | "author": { 43 | "name": "Forbes Lindesay", 44 | "url": "http://github.com/ForbesLindesay" 45 | }, 46 | "license": "MIT" 47 | } 48 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV === 'production') { 2 | require('./lib'); 3 | } else { 4 | require('babel-register'); 5 | require('./src'); 6 | } 7 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | import mongo from 'then-mongo'; 2 | 3 | const db = mongo( 4 | process.env.DATABASE, 5 | ['stories'], 6 | ); 7 | const ObjectId = db.ObjectId; 8 | 9 | let storiesCache = _getStories(); 10 | let cacheById = {}; 11 | 12 | // CREATE 13 | export function addStory(storyBody) { 14 | if (typeof storyBody !== 'string') { 15 | throw new TypeError( 16 | 'story body must be a string', 17 | ); 18 | } 19 | return db.stories.insert({ 20 | body: storyBody, 21 | votes: 0, 22 | }).then(result => { 23 | storiesCache = _getStories(); 24 | cacheById = {}; 25 | return result; 26 | }); 27 | }; 28 | 29 | function _getStories() { 30 | return db.stories.find().then(stories => { 31 | return stories.sort((a, b) => b.votes - a.votes); 32 | }); 33 | } 34 | 35 | // READ 36 | export function getStoryIds() { 37 | return storiesCache.then(stories => { 38 | return stories.map(s => s._id); 39 | }); 40 | }; 41 | export function getStory(id) { 42 | if (typeof id !== 'string') { 43 | throw new TypeError( 44 | 'story id must be a string', 45 | ); 46 | } 47 | if (id in cacheById) return cacheById[id]; 48 | return cacheById[id] = db.stories.findOne({_id: new ObjectId(id)}); 49 | }; 50 | export function getStories() { 51 | return storiesCache; 52 | } 53 | 54 | // UPDATE 55 | export function voteStory(id) { 56 | if (typeof id !== 'string') { 57 | throw new TypeError( 58 | 'story id must be a string', 59 | ); 60 | } 61 | return db.stories.update( 62 | {_id: new ObjectId(id)}, 63 | {$inc: {votes: 1}}, 64 | ).then(result => { 65 | storiesCache = _getStories(); 66 | cacheById = {}; 67 | return result; 68 | }); 69 | }; 70 | -------------------------------------------------------------------------------- /src/bicycle-schema/objects/root.js: -------------------------------------------------------------------------------- 1 | import * as api from '../../api'; 2 | 3 | export default { 4 | name: 'Root', 5 | fields: { 6 | stories: { 7 | type: 'Story[]', 8 | args: {}, 9 | resolve(root, args, context) { 10 | return api.getStories(); 11 | }, 12 | }, 13 | }, 14 | mutations: { 15 | refresh: { 16 | args: {}, 17 | resolve(args, context) {}, // NO-OP 18 | }, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /src/bicycle-schema/objects/story.js: -------------------------------------------------------------------------------- 1 | import * as api from '../../api'; 2 | 3 | export default { 4 | name: 'Story', 5 | id(story) { 6 | return 'Story:' + story._id; 7 | }, 8 | fields: { 9 | id: { 10 | type: 'string', 11 | resolve(story, args, context) { 12 | return '' + story._id; 13 | }, 14 | }, 15 | body: 'string', 16 | votes: 'number', 17 | }, 18 | mutations: { 19 | create: { 20 | args: {body: 'string'}, 21 | resolve({body}, context) { 22 | return api.addStory(body); 23 | }, 24 | }, 25 | vote: { 26 | args: {id: 'string'}, 27 | resolve({id}, context) { 28 | return api.voteStory(id); 29 | }, 30 | }, 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /src/bicycle/components/app.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {connect} from 'react-bicycle'; 3 | import Story from './story'; 4 | 5 | class App extends Component { 6 | constructor() { 7 | super(); 8 | this.state = {body: ''}; 9 | this._onChangeBody = this._onChangeBody.bind(this); 10 | this._onSubmit = this._onSubmit.bind(this); 11 | } 12 | _onChangeBody(e) { 13 | this.setState({body: e.target.value}); 14 | } 15 | _onSubmit(e) { 16 | e.preventDefault(); 17 | if (!this.state.body) return; 18 | this.props.addStory(this.state.body); 19 | this.setState({body: ''}); 20 | } 21 | render() { 22 | return ( 23 |
24 | {this.props.stories.map(story => ( 25 | 26 | ))} 27 |
28 | 29 | 30 |
31 |
32 | ); 33 | } 34 | } 35 | 36 | export default connect( 37 | props => ({stories: {id: true, ...Story.fields}}), 38 | (client, props) => ({ 39 | addStory(body) { 40 | client.update('Story.create', {body}); 41 | }, 42 | }), 43 | )(App); 44 | -------------------------------------------------------------------------------- /src/bicycle/components/story.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import request from 'then-request'; 3 | import {connect} from 'react-bicycle'; 4 | 5 | class Story extends Component { 6 | render() { 7 | return ( 8 |
9 | 10 | {this.props.story.votes} 11 | 12 |
{this.props.story.body}
13 | 14 |
15 | ); 16 | } 17 | } 18 | 19 | const StoryContainer = connect( 20 | undefined, 21 | (client, props) => ({ 22 | onVote() { 23 | client.update('Story.vote', {id: props.story.id}); 24 | }, 25 | }), 26 | )(Story); 27 | 28 | StoryContainer.fields = { 29 | id: true, 30 | votes: true, 31 | body: true, 32 | }; 33 | 34 | export default StoryContainer; 35 | -------------------------------------------------------------------------------- /src/bicycle/index.js: -------------------------------------------------------------------------------- 1 | import Bicycle from 'bicycle/lib/client'; 2 | import {Provider} from 'react-bicycle'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import App from './components/app'; 6 | 7 | 8 | const client = new Bicycle(); 9 | 10 | client.definieOptimisticUpdaters({ 11 | Story: { 12 | vote({args: {id}}, cache) { 13 | if (!cache['Story:' + id]) return {}; 14 | return {['Story:' + id]: {'votes': cache['Story:' + id].votes + 1}}; 15 | }, 16 | }, 17 | }); 18 | 19 | setInterval( 20 | () => client.update('Root.refresh', {}), 21 | 3000, 22 | ); 23 | 24 | ReactDOM.render( 25 | 26 | 27 | , 28 | document.getElementById('container'), 29 | ); 30 | -------------------------------------------------------------------------------- /src/data-store/components/app.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {addStory} from '../data-store'; 3 | import withData from './with-data'; 4 | import Story from './story'; 5 | 6 | class App extends Component { 7 | constructor() { 8 | super(); 9 | this.state = {body: ''}; 10 | this._onChangeBody = this._onChangeBody.bind(this); 11 | this._onSubmit = this._onSubmit.bind(this); 12 | } 13 | _onChangeBody(e) { 14 | this.setState({body: e.target.value}); 15 | } 16 | _onSubmit(e) { 17 | e.preventDefault(); 18 | if (!this.state.body) return; 19 | addStory(this.state.body); 20 | this.setState({body: ''}); 21 | } 22 | render() { 23 | return ( 24 |
25 | {this.props.data.map(id => ( 26 | 27 | ))} 28 |
29 | 30 | 31 |
32 |
33 | ); 34 | } 35 | } 36 | 37 | export default withData( 38 | () => '/api/stories', 39 | App, 40 | ); 41 | -------------------------------------------------------------------------------- /src/data-store/components/spinner.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | 3 | function Spinner() { 4 | return
Loading...
; 5 | } 6 | 7 | export default Spinner; 8 | -------------------------------------------------------------------------------- /src/data-store/components/story.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import request from 'then-request'; 3 | import {voteStory} from '../data-store'; 4 | import withData from './with-data'; 5 | 6 | class Story extends Component { 7 | constructor() { 8 | super(); 9 | this._onVote = this._onVote.bind(this); 10 | } 11 | _onVote() { 12 | voteStory(this.props.id); 13 | } 14 | render() { 15 | return ( 16 |
17 | 18 | {this.props.data.votes} 19 | 20 |
{this.props.data.body}
21 | 22 |
23 | ); 24 | } 25 | } 26 | 27 | export default withData( 28 | props => '/api/stories/' + props.id, 29 | Story, 30 | ); 31 | -------------------------------------------------------------------------------- /src/data-store/components/with-data.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import request from 'then-request'; 3 | import {subscribe} from '../data-store'; 4 | import Spinner from './spinner'; 5 | 6 | function equal(a, b) { 7 | return a === b; 8 | } 9 | function withData(getQuery, Component) { 10 | return class DataFetching extends Component { 11 | constructor() { 12 | super(); 13 | this.state = {loading: true, data: null}; 14 | } 15 | subscribe(props) { 16 | this._unsubscribe = subscribe( 17 | getQuery(props), 18 | (loading, data) => this.setState({loading, data}), 19 | ); 20 | } 21 | componentDidMount() { 22 | this.subscribe(this.props); 23 | } 24 | componentWillReceiveProps(newProps) { 25 | if (!equal(getQuery(this.props), getQuery(newProps))) { 26 | this._unsubscribe(); 27 | this.subscribe(); 28 | } 29 | } 30 | componentWillUnmount() { 31 | this._unsubscribe(); 32 | } 33 | render() { 34 | if (this.state.loading) return ; 35 | return ; 36 | } 37 | }; 38 | } 39 | 40 | export default withData; 41 | -------------------------------------------------------------------------------- /src/data-store/data-store.js: -------------------------------------------------------------------------------- 1 | import request from 'then-request'; 2 | 3 | const subscriptions = new Set(); 4 | const cache = {}; 5 | const inFlight = {}; 6 | 7 | export function subscribe(query, fn) { 8 | // fn(loading, data); 9 | fn(cache[query] === undefined, cache[query]); 10 | fetchQuery(query); 11 | const subscription = {query, fn}; 12 | subscriptions.add(subscription); 13 | return () => subscriptions.remove(subscription); 14 | }; 15 | 16 | function fetchQuery(query) { 17 | if (inFlight[query]) return; 18 | inFlight[query] = true; 19 | request('get', query).getBody('utf8').then(JSON.parse).done( 20 | data => { 21 | cache[query] = data; 22 | updateSubscriptions(query); 23 | }, 24 | err => { 25 | // TODO: handle errors properly 26 | inFlight[query] = false; 27 | updateSubscriptions(query); 28 | throw err; 29 | }, 30 | ); 31 | } 32 | 33 | function updateSubscriptions(query) { 34 | subscriptions.forEach(subscription => { 35 | if (subscription.query === query) { 36 | subscription.fn( 37 | cache[query] === undefined, 38 | cache[query], 39 | ); 40 | } 41 | }); 42 | } 43 | 44 | export function addStory(body) { 45 | request('put', '/api/stories', {json: {body}}).getBody('utf8').then(JSON.parse).done( 46 | result => { 47 | cache['/api/stories'] = cache['/api/stories'].concat([result._id]); 48 | cache['/api/stories/' + result._id] = result; 49 | updateSubscriptions('/api/stories'); 50 | updateSubscriptions('/api/stories/' + result._id); 51 | }, 52 | // TODO: handle errors 53 | ); 54 | } 55 | export function voteStory(id) { 56 | // optimistic update 57 | const oldValue = cache['/api/stories/' + id]; 58 | if (cache['/api/stories/' + id]) { 59 | cache['/api/stories/' + id] = { 60 | ...cache['/api/stories/' + id], 61 | votes: cache['/api/stories/' + id].votes + 1, 62 | }; 63 | updateSubscriptions('/api/stories/' + id); 64 | } 65 | request('post', '/api/stories/' + id + '/vote').getBody('utf8').then(JSON.parse).done( 66 | result => { 67 | cache['/api/stories'] = result.storyIds; 68 | cache['/api/stories/' + id] = result.story; 69 | updateSubscriptions('/api/stories'); 70 | updateSubscriptions('/api/stories/' + id); 71 | }, 72 | err => { 73 | // roll back optimistic update 74 | cache['/api/stories/' + id] = oldValue; 75 | updateSubscriptions('/api/stories/' + id); 76 | // TODO: proper error handling 77 | throw err; 78 | } 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/data-store/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './components/app'; 4 | 5 | ReactDOM.render( 6 | , 7 | document.getElementById('container'), 8 | ); 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import {readFileSync} from 'fs'; 2 | import {resolve} from 'path'; 3 | import Promise from 'promise'; 4 | import browserify from 'browserify-middleware'; 5 | import express from 'express'; 6 | import prepare from 'prepare-response'; 7 | import {json} from 'body-parser'; 8 | import MemoryStore from 'bicycle/sessions/memory'; 9 | import {createBicycleMiddleware, loadSchemaFromFiles} from 'bicycle/server'; 10 | import * as api from './api'; 11 | 12 | const app = express(); 13 | 14 | const index = readFileSync(__dirname + '/../index.html', 'utf8'); 15 | [ 16 | 'raw', 17 | 'with-data', 18 | 'data-store', 19 | 'bicycle', 20 | ].forEach(implementation => { 21 | const response = prepare( 22 | index.replace(/\{\{client\}\}/, '/' + implementation + '.js'), 23 | {'content-type': 'html'}, 24 | ); 25 | app.get('/' + implementation, (req, res, next) => { 26 | response.send(req, res, next); 27 | }); 28 | app.get('/' + implementation + '.js', browserify( 29 | __dirname + '/' + implementation + '/index.js', 30 | { 31 | transform: ( 32 | process.env.NODE_ENV === 'production' 33 | ? [ 34 | [require('envify'), {global: true}], 35 | [require('uglifyify'), {global: true}], 36 | ] 37 | : require('babelify') 38 | ), 39 | }, 40 | )); 41 | }); 42 | app.get('/', (req, res) => { 43 | res.sendFile(resolve(__dirname + '/../home.html')); 44 | }); 45 | 46 | // CREATE 47 | app.put('/api/stories', json(), (req, res, next) => { 48 | api.addStory(req.body.body).done(r => res.json(r), next); 49 | }); 50 | 51 | // READ 52 | app.get('/api/stories', (req, res, next) => { 53 | api.getStoryIds().done(r => res.json(r), next); 54 | }); 55 | app.get('/api/stories/:id', (req, res, next) => { 56 | api.getStory(req.params.id).done(r => res.json(r), next); 57 | }); 58 | 59 | // UPDATE 60 | app.post('/api/stories/:id/vote', (req, res, next) => { 61 | api.voteStory(req.params.id).then( 62 | () => Promise.all([api.getStory(req.params.id), api.getStoryIds()]) 63 | ).then( 64 | results => ({story: results[0], storyIds: results[1]}) 65 | ).done(r => res.json(r), next); 66 | }); 67 | 68 | const schema = loadSchemaFromFiles(__dirname + '/bicycle-schema'); 69 | const sessionStore = new MemoryStore(); 70 | app.use('/bicycle', createBicycleMiddleware(schema, sessionStore, getContext)); 71 | 72 | function getContext(req) { 73 | // use this function to add "context" such as the currently logged in user 74 | // to bicycle requests 75 | return {}; 76 | } 77 | 78 | app.listen(process.env.PORT || 3000); 79 | -------------------------------------------------------------------------------- /src/raw/components/app.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import request from 'then-request'; 3 | import Story from './story'; 4 | import Spinner from './spinner'; 5 | 6 | class App extends Component { 7 | constructor() { 8 | super(); 9 | this.state = {loading: true, stories: null, body: ''}; 10 | this._onUpdateOrder = this._onUpdateOrder.bind(this); 11 | this._onChangeBody = this._onChangeBody.bind(this); 12 | this._onSubmit = this._onSubmit.bind(this); 13 | } 14 | componentDidMount() { 15 | this._onUpdateOrder(); 16 | } 17 | _onUpdateOrder() { 18 | request('get', '/api/stories').getBody('utf8').then(JSON.parse).done( 19 | stories => this.setState({loading: false, stories}) 20 | ); 21 | } 22 | _onChangeBody(e) { 23 | this.setState({body: e.target.value}); 24 | } 25 | _onSubmit(e) { 26 | e.preventDefault(); 27 | if (!this.state.body) return; 28 | request('put', '/api/stories', { 29 | json: {body: this.state.body}, 30 | }).getBody('utf8').done( 31 | () => this._onUpdateOrder() 32 | ); 33 | this.setState({body: ''}); 34 | } 35 | render() { 36 | if (this.state.loading) return ; 37 | return ( 38 |
39 | {this.state.stories.map(id => ( 40 | 41 | ))} 42 |
43 | 44 | 45 |
46 |
47 | ); 48 | } 49 | } 50 | 51 | export default App; 52 | -------------------------------------------------------------------------------- /src/raw/components/spinner.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | 3 | function Spinner() { 4 | return
Loading...
; 5 | } 6 | 7 | export default Spinner; 8 | -------------------------------------------------------------------------------- /src/raw/components/story.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import request from 'then-request'; 3 | import Spinner from './spinner'; 4 | 5 | class Story extends Component { 6 | constructor() { 7 | super(); 8 | this.state = {loading: true, story: null}; 9 | this._onVote = this._onVote.bind(this); 10 | } 11 | componentDidMount() { 12 | request('get', '/api/stories/' + this.props.id) 13 | .getBody('utf8').then(JSON.parse).done( 14 | story => this.setState({loading: false, story}) 15 | ); 16 | } 17 | _onVote() { 18 | // Optimistic update 19 | this.setState({ 20 | story: { 21 | ...this.state.story, 22 | votes: this.state.story.votes + 1, 23 | }, 24 | }); 25 | // actual update 26 | request('post', '/api/stories/' + this.props.id + '/vote') 27 | .getBody('utf8').then(JSON.parse).done( 28 | result => { 29 | this.setState({loading: false, story: result.story}); 30 | this.props.onUpdateOrder(); 31 | } 32 | ); 33 | } 34 | render() { 35 | if (this.state.loading) return ; 36 | return ( 37 |
38 | 39 | {this.state.story.votes} 40 | 41 |
{this.state.story.body}
42 | 43 |
44 | ); 45 | } 46 | } 47 | 48 | export default Story; 49 | -------------------------------------------------------------------------------- /src/raw/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './components/app'; 4 | 5 | ReactDOM.render( 6 | , 7 | document.getElementById('container'), 8 | ); 9 | -------------------------------------------------------------------------------- /src/with-data/components/app.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import request from 'then-request'; 3 | import withData from './with-data'; 4 | import Story from './story'; 5 | 6 | class App extends Component { 7 | constructor() { 8 | super(); 9 | this.state = {body: ''}; 10 | this._onChangeBody = this._onChangeBody.bind(this); 11 | this._onSubmit = this._onSubmit.bind(this); 12 | } 13 | _onChangeBody(e) { 14 | this.setState({body: e.target.value}); 15 | } 16 | _onSubmit(e) { 17 | e.preventDefault(); 18 | if (!this.state.body) return; 19 | request('put', '/api/stories', { 20 | json: {body: this.state.body}, 21 | }).getBody('utf8').then(JSON.parse).done( 22 | () => this.props.onUpdate() 23 | ); 24 | this.setState({body: ''}); 25 | } 26 | render() { 27 | return ( 28 |
29 | {this.props.data.map(id => ( 30 | 31 | ))} 32 |
33 | 34 | 35 |
36 |
37 | ); 38 | } 39 | } 40 | 41 | export default withData( 42 | () => '/api/stories', 43 | App, 44 | ); 45 | -------------------------------------------------------------------------------- /src/with-data/components/spinner.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | 3 | function Spinner() { 4 | return
Loading...
; 5 | } 6 | 7 | export default Spinner; 8 | -------------------------------------------------------------------------------- /src/with-data/components/story.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import request from 'then-request'; 3 | import withData from './with-data'; 4 | 5 | class Story extends Component { 6 | constructor() { 7 | super(); 8 | this._onVote = this._onVote.bind(this); 9 | } 10 | _onVote() { 11 | request('post', '/api/stories/' + this.props.id + '/vote') 12 | .getBody('utf8').then(JSON.parse).done( 13 | story => this.props.onUpdate() 14 | ); 15 | } 16 | render() { 17 | return ( 18 |
19 | 20 | {this.props.data.votes} 21 | 22 |
{this.props.data.body}
23 | 24 |
25 | ); 26 | } 27 | } 28 | 29 | export default withData( 30 | props => '/api/stories/' + props.id, 31 | Story, 32 | ); 33 | -------------------------------------------------------------------------------- /src/with-data/components/with-data.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import request from 'then-request'; 3 | import Spinner from './spinner'; 4 | 5 | function withData(getUrl, Component) { 6 | return class DataFetching extends Component { 7 | constructor() { 8 | super(); 9 | this.state = {loading: true, data: null}; 10 | this._onUpdate = this._onUpdate.bind(this); 11 | } 12 | componentDidMount() { 13 | this._onUpdate(); 14 | } 15 | _onUpdate() { 16 | request('get', getUrl(this.props)).getBody('utf8').then(JSON.parse).then( 17 | data => { 18 | this.setState({loading: false, data}); 19 | if (this.props.onUpdate) { 20 | this.props.onUpdate(); 21 | } 22 | } 23 | ); 24 | } 25 | render() { 26 | if (this.state.loading) return ; 27 | return ( 28 | 33 | ); 34 | } 35 | }; 36 | } 37 | 38 | export default withData; 39 | -------------------------------------------------------------------------------- /src/with-data/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './components/app'; 4 | 5 | ReactDOM.render( 6 | , 7 | document.getElementById('container'), 8 | ); 9 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | console.log('no tests :('); 2 | --------------------------------------------------------------------------------