├── .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 | [](https://travis-ci.org/ForbesLindesay/react-data-fetching-demo)
6 | [](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 | - raw - a raw react implementation
16 | - with-data - an implementation using a
withData
higher order component
17 | - data-store - an implementation using a centralised data store
18 | - bicycle - an implementation using the bicycle data fetching library
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------