Learning Full-Stack JavaScript Development: MongoDB, Node and React


6. Routing on Client and Server


023 Handling the contest click event


App.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import React from 'react';
import axios from 'axios';
import Header from './Header';
import ContestList from './ContestList';


class App extends React.Component {
    
    constructor(props){
        super(props);
        
        this.state = { 
            pageHeader: 'Naming Contests',
            contests: this.props.initialContests
        };
    }
        
    componentDidMount() {

    }
    
    componentWillUnmount() {
        
    }

    render() {  
              
        return (
            <h2 className="App">
                <Header message= {this.state.pageHeader} />
                <ContestList contests={this.state.contests} />
            </h2>
        );
    }
};

export default App;


ContestList.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React from 'react';
import ContestPreview from './ContestPreview';
import PropTypes from 'prop-types';

const ContestList = ({ contests }) => (
    <div className="ContestList">      
        {contests.map(contest =>                        
            <ContestPreview key={contest.id} {...contest} />
        )}
    </div>
);

ContestList.propTypes = {
    contests: PropTypes.array
}

export default ContestList;


ContestPreview.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import React, { Component } from 'react';
import PropTypes from 'prop-types';

class ContestPreview extends Component {
    
    constructor(props){
    super(props);
    this.handleClick = this.handleClick.bind(this);
}

    handleClick(){
        console.log(this.props.id);
    };
    
    render() {
        return (
            <div className="link ContestPreview" onClick={this.handleClick}>
                <div className="category-name">
                    {this.props.categoryName}
                </div>
                <div className="contest-name">
                    <div>{this.props.contestName}</div>
                </div>
            </div>
        );
    }
}

ContestPreview.propTypes = {
    categoryName: PropTypes.string.isRequired,
    contestName: PropTypes.string.isRequired
}

export default ContestPreview;


024 Navigating to a contest


App.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import React from 'react';
import axios from 'axios';
import Header from './Header';
import ContestList from './ContestList';

const pushState = (obj, url) => window.history.pushState(obj, '', url);


class App extends React.Component {
    
    constructor(props){
        super(props);
        
        this.state = { 
            pageHeader: 'Naming Contests',
            contests: this.props.initialContests
        };
    }
        
    componentDidMount() {

    }
    
    componentWillUnmount() {
        
    }
    
    fetchContest(contestId){
        pushState(
            { currentContestId: contestId },
            `/contest/${contestId}`
        )
    };
    
    render() {  
              
        return (
            <h2 className="App">
                <Header message= {this.state.pageHeader} />
                <ContestList 
                    onContestClick={this.fetchContest}
                    contests={this.state.contests} />
            </h2>
        );
    }
};

export default App;


ContestList.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React from 'react';
import ContestPreview from './ContestPreview';
import PropTypes from 'prop-types';

const ContestList = ({ contests, onContestClick }) => (
    <div className="ContestList">      
        {contests.map(contest =>                        
            <ContestPreview 
                key={contest.id} 
                onClick={ onContestClick }
                {...contest} />
        )}
    </div>
);

ContestList.propTypes = {
    contests: PropTypes.array,
    onContestClick: PropTypes.func.isRequired
}

export default ContestList;


ContestPreview.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import React, { Component } from 'react';
import PropTypes from 'prop-types';

class ContestPreview extends Component {
    
    constructor(props){
    super(props);
    this.handleClick = this.handleClick.bind(this);
}

    handleClick(){
        // console.log(this.props.id);
        this.props.onClick(this.props.id);
    };
    
    render() {
        return (
            <div className="link ContestPreview" onClick={this.handleClick}>
                <div className="category-name">
                    {this.props.categoryName}
                </div>
                <div className="contest-name">
                    <div>{this.props.contestName}</div>
                </div>
            </div>
        );
    }
}

ContestPreview.propTypes = {
    id: PropTypes.number.isRequired,
    categoryName: PropTypes.string.isRequired,
    contestName: PropTypes.string.isRequired,
    onClick: PropTypes.func.isRequired
}

export default ContestPreview;


025 Looking up the contest on route change


App.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import React from 'react';
import axios from 'axios';
import Header from './Header';
import ContestList from './ContestList';
import Contest from './Contest';

const pushState = (obj, url) => window.history.pushState(obj, '', url);


class App extends React.Component {
    
    constructor(props){
        super(props);
        this.fetchContest = this.fetchContest.bind(this);
        this.currentContent = this.currentContent.bind(this);
        
        this.state = { 
            pageHeader: 'Naming Contests',
            contests: this.props.initialContests
        };
    }
        
    componentDidMount() {

    }
    
    componentWillUnmount() {
        
    }
    
    fetchContest(contestId){
        
        pushState(
            { currentContestId: contestId },
            `/contest/${contestId}`
        );
        
        this.setState({
            pageHeader: this.state.contests[contestId].contestName,
            currentContestId: contestId
        });
    };
    
    currentContent() {
        if (this.state.currentContestId) {
            return <Contest {...this.state.contests[this.state.currentContestId]} />;
        } 
        
        return <ContestList 
            onContestClick={this.fetchContest}
            contests={this.state.contests} />;
    }
    
    render() {  
              
        return (
            <h2 className="App">
                <Header message= {this.state.pageHeader} />
                {this.currentContent()}
            </h2>
        );
    }
};

export default App;


Contest.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React, { Component} from 'react';
import PropTypes from 'prop-types';

class Contest extends React.Component {
    render() {
        return (
            <div className="Contest">
                {this.props.id}
            </div>
        );
        
    }
}

Contest.propTypes = {
    id: PropTypes.number.isRequired
}

export default Contest;


ContestList.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React from 'react';
import ContestPreview from './ContestPreview';
import PropTypes from 'prop-types';

const ContestList = ({ contests, onContestClick }) => (
    <div className="ContestList">      
        {Object.keys(contests).map(contestId =>                        
            <ContestPreview 
                key={contestId} 
                onClick={ onContestClick }
                {...contests[contestId]} />
        )}
    </div>
);

ContestList.propTypes = {
    contests: PropTypes.object,
    onContestClick: PropTypes.func.isRequired
}

export default ContestList;


026 Fetching contest information from the API


api/index.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import express from 'express';
import data from '../src/testData';

const router = express.Router();

const contests = data.contests.reduce((obj, contest) => {
    obj[contest.id] = contest;
    return obj;
}, {});

router.get('/contests', (req, res) => {
    res.send({ contests: contests });
});

router.get('/contests/:contestId', (req, res) => {
    let contest = contests[req.params.contestId];
    contest.description = 'Any Text';
    res.send({ contest });
});

export default router;


api.js


1
2
3
4
5
6
import axios from 'axios';

export const fetchContest = contestId => {
    return axios.get(`/api/contests/${contestId}`)
        .then( resp => resp.data.contest )
};


Contest.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React, { Component} from 'react';
import PropTypes from 'prop-types';

class Contest extends React.Component {
    
    render() {
        
        return (
            <div className="Contest">
                {this.props.description}
            </div>
        );
    }
}

Contest.propTypes = {
    description: PropTypes.string.isRequired
}

export default Contest;


App.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import React from 'react';
import axios from 'axios';
import Header from './Header';
import ContestList from './ContestList';
import Contest from './Contest';
import * as api  from '../api';

const pushState = (obj, url) => window.history.pushState(obj, '', url);


class App extends React.Component {
    
    constructor(props){
        
        super(props);
        
        this.fetchContest = this.fetchContest.bind(this);
        this.currentContent = this.currentContent.bind(this);
        
        this.state = { 
            pageHeader: 'Naming Contests',
            contests: this.props.initialContests,
        };
    }
        
    componentDidMount() {

    }
    
    componentWillUnmount() {
        
    }
    
    fetchContest(contestId){
        
        pushState(
            { currentContestId: contestId },
            `/contest/${contestId}`
        );
        
        
        api.fetchContest(contestId).then(contest => {
            
            this.setState({
                pageHeader: contest.contestName,
                currentContestId: contest.id,
                contests: {
                    [contest.id]: contest
                }
            });            
        });
    };
    
    currentContent() {
        
        if (this.state.currentContestId) {
            return <Contest {...this.state.contests[this.state.currentContestId]} />;
        } 
        
        return <ContestList 
            onContestClick={this.fetchContest}
            contests={this.state.contests} />;
    }
    
    render() {  
              
        return (
            <h2 className="App">
                <Header message= {this.state.pageHeader} />
                {this.currentContent()}
            </h2>
        );
    }
};

export default App;

http://localhost/api/contests/4


027 A bit of refactoring


App.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import React from 'react';
import axios from 'axios';
import Header from './Header';
import ContestList from './ContestList';
import Contest from './Contest';
import * as api  from '../api';
import PropTypes from 'prop-types';

const pushState = (obj, url) => window.history.pushState(obj, '', url);


class App extends React.Component {
    
    constructor(props){
        
        super(props);
        
        this.fetchContest = this.fetchContest.bind(this);
        this.currentContent = this.currentContent.bind(this);
                
        // static propTypes = {
        //     initialData: PropTypes.object.isRequired
        // }

        this.state = this.props.initialData;
    }
        
    componentDidMount() {

    }
    
    componentWillUnmount() {
        
    }
    
    fetchContest(contestId){
        
        pushState(
            { currentContestId: contestId },
            `/contest/${contestId}`
        );
        
        
        api.fetchContest(contestId).then(contest => {
            
            this.setState({
                currentContestId: contest.id,
                contests: {
                    [contest.id]: contest
                }
            });            
        });
    };
    
    
    currentContest(){
        return this.state.contests[this.state.currentContestId]
    }
    
    pageHeader() {
        if(this.state.currentContestId){
            return this.currentContest().contestName;
        }
        
        return 'Naming Contests';

    }
    
    currentContent() {
        
        if (this.state.currentContestId) {
            return <Contest {...this.currentContest()} />;
        } 
        
        return <ContestList 
            onContestClick={this.fetchContest}
            contests={this.state.contests} />;
    }
    
    render() {  
              
        return (
            <h2 className="App">
                <Header message= {this.pageHeader()} />
                {this.currentContent()}
            </h2>
        );
    }
};

export default App;


index.js


1
2
3
4
5
6
7
8
9
import React from 'react';
import ReactDOM from 'react-dom';

import App from './components/App';

ReactDOM.render(
    <App initialData={window.initialData} />,
    document.getElementById('root')
);


serverRender.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React from 'react';
import ReactDOMServer from 'react-dom/server';

import App from './src/components/App';

import config from './config';
import axios from 'axios';

const serverRender = () =>
    axios.get(`${config.serverUrl}/api/contests`)
        .then(resp => {
            return {
                initialMarkup: ReactDOMServer.renderToString(
                <App initialData={resp.data} />
                ),
                initialData: resp.data
            };
        });

export default serverRender;


028 Server-side routing for a contest



server.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import config from './config';
import apiRouter from './api';
import sassMiddleware from 'node-sass-middleware';
import path from 'path';
import serverRender from './serverRender';

import express from 'express';
const server = express();

server.use(sassMiddleware({
    src: path.join(__dirname, 'sass'),
    dest: path.join(__dirname, 'public')
}));

server.set('view engine', 'ejs');

server.get(['/', '/contest/:contestId'], (req, res) => {    
    serverRender(req.params.contestId)
        .then(( {initialMarkup, initialData}) => {
            res.render('index', {
                initialMarkup,
                initialData
            });
        })
        .catch(console.error);
});

server.get('/about.html', (req, res) => {
    res.send('The about page');
});

server.use('/api', apiRouter);
server.use(express.static('public'));

server.listen(config.port, config.host, () => {
    console.info('Express listening on port ', config.port);
});


serverRender.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import React from 'react';
import ReactDOMServer from 'react-dom/server';

import App from './src/components/App';

import config from './config';
import axios from 'axios';

const getApiUrl = contestId => {
    
    console.log('contestId1');
    console.log(contestId);
    
    if (contestId) {
        return `${config.serverUrl}/api/contests/${contestId}`;
    }
    return `${config.serverUrl}/api/contests`;
}

const getInitialData = (contestId, apiData) => {    
    if (contestId){
        return {
            currentContestId: apiData.contest.id,
            contests: {
                [apiData.contest.id]: apiData.contest
            }
        };
    }
    
    return {
        contests: apiData.contests
    }
};

const serverRender = (contestId) =>
    axios.get(getApiUrl(contestId))
        .then(resp => {

            const initialData = getInitialData(contestId, resp.data);
            
            return {
                initialMarkup: ReactDOMServer.renderToString(
                <App initialData={initialData} />
                ),
                initialData
            };
        });

export default serverRender;


029 Navigating to a list of contests


api.js


1
2
3
4
5
6
7
8
9
10
11
import axios from 'axios';

export const fetchContest = contestId => {
    return axios.get(`/api/contests/${contestId}`)
        .then( resp => resp.data.contest )
};

export const fetchContestList = () => {
    return axios.get(`/api/contests`)
        .then( resp => resp.data.contests )
};


Contest.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React, { Component} from 'react';
import PropTypes from 'prop-types';

class Contest extends React.Component {
    
    render() {
        
        return (
            <div className="Contest">
                <div className="contest-description">
                    {this.props.description}
                </div>
                <div className="home-link link"
                    onClick={this.props.contestListClick}
                    >
                    Contest List
                </div>
            </div>
        );
    }
}

Contest.propTypes = {
    description: PropTypes.string.isRequired,
    contestListClick: PropTypes.func.isRequired
}

export default Contest;


App.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
import React from 'react';
import axios from 'axios';
import Header from './Header';
import ContestList from './ContestList';
import Contest from './Contest';
import * as api  from '../api';
import PropTypes from 'prop-types';

const pushState = (obj, url) => window.history.pushState(obj, '', url);


class App extends React.Component {
    
    constructor(props){
        
        super(props);
        
        this.fetchContest = this.fetchContest.bind(this);
        this.fetchContestList = this.fetchContestList.bind(this);
        this.currentContent = this.currentContent.bind(this);
                
        // static propTypes = {
        //     initialData: PropTypes.object.isRequired
        // }

        this.state = this.props.initialData;
    }
        
    componentDidMount() {

    }
    
    componentWillUnmount() {
        
    }
    
    fetchContest(contestId) {
        
        pushState(
            { currentContestId: contestId },
            `/contest/${contestId}`
        );
        
        
        api.fetchContest(contestId).then(contest => {
            
            this.setState({
                currentContestId: contest.id,
                contests: {
                    [contest.id]: contest
                }
            });            
        });
    };
    
    
    fetchContestList() {
        
        pushState(
            { currentContestId: null },
            `/`
        );
        
        api.fetchContestList().then(contests => {
            
            this.setState({
                currentContestId: null,
                contests
            });            
        });
    };
    
    
    currentContest(){
        return this.state.contests[this.state.currentContestId]
    }
    
    pageHeader() {
        if(this.state.currentContestId){
            return this.currentContest().contestName;
        }
        
        return 'Naming Contests';

    }
    
    currentContent() {
        
        if (this.state.currentContestId) {
            return <Contest 
                contestListClick={this.fetchContestList}
                {...this.currentContest()} />;
        } 
        
        return <ContestList 
            onContestClick={this.fetchContest}
            contests={this.state.contests} />;
    }
    
    render() {  
              
        return (
            <h2 className="App">
                <Header message= {this.pageHeader()} />
                {this.currentContent()}
            </h2>
        );
    }
};

export default App;


030 Handling the browser’s back button


App.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
import React from 'react';
import axios from 'axios';
import Header from './Header';
import ContestList from './ContestList';
import Contest from './Contest';
import * as api  from '../api';
import PropTypes from 'prop-types';

const pushState = (obj, url) => window.history.pushState(obj, '', url);

const onPopState = handler => {
    window.onpopstate = handler;
}

class App extends React.Component {
    
    constructor(props){
        
        super(props);
        
        this.fetchContest = this.fetchContest.bind(this);
        this.fetchContestList = this.fetchContestList.bind(this);
        this.currentContent = this.currentContent.bind(this);
                
        this.state = this.props.initialData;
    }
        
    componentDidMount() {
        onPopState((event) => {
            this.setState({
                currentContestId: (event.state || {}).currentContestId
            })
        });
    }
    
    componentWillUnmount() {
        onPopState(null);
    }
    
    fetchContest(contestId) {
        
        pushState(
            { currentContestId: contestId },
            `/contest/${contestId}`
        );
        
        
        api.fetchContest(contestId).then(contest => {
            
            this.setState({
                currentContestId: contest.id,
                contests: {
                    [contest.id]: contest
                }
            });            
        });
    };
    
    
    fetchContestList() {
        
        pushState(
            { currentContestId: null },
            `/`
        );
        
        api.fetchContestList().then(contests => {
            
            this.setState({
                currentContestId: null,
                contests
            });            
        });
    };
    
    
    currentContest(){
        return this.state.contests[this.state.currentContestId]
    }
    
    pageHeader() {
        if(this.state.currentContestId){
            return this.currentContest().contestName;
        }
        
        return 'Naming Contests';

    }
    
    currentContent() {
        
        if (this.state.currentContestId) {
            return <Contest 
                contestListClick={this.fetchContestList}
                {...this.currentContest()} />;
        } 
        
        return <ContestList 
            onContestClick={this.fetchContest}
            contests={this.state.contests} />;
    }
    
    render() {  
              
        return (
            <h2 className="App">
                <Header message= {this.pageHeader()} />
                {this.currentContent()}
            </h2>
        );
    }
};

export default App;