My FeedDiscussionsHeadless CMS
New
Sign in
Log inSign up
Learn more about Hashnode Headless CMSHashnode Headless CMS
Collaborate seamlessly with Hashnode Headless CMS for Enterprise.
Upgrade ✨Learn more

Preact with Redux and React-Router

Yanni Nightingale's photo
Yanni Nightingale
·Sep 12, 2016

I'am always looking for a great substitute of react to optimize code's size. Maybe I won't use all the features of react, then I found preact.

If you're using react, you must need some libraries around it, like redux, react-router or something else. So the substitute is best compatible with them, or with some fixing solution.

I was wondering how preact acts as react, so I tried to build an app with redux/react-router/react-redux/react-router-redux, the most common libraries in react ecosystem.

Yes, there is a preact-router, but I have to modify some codes if I decide to switch to react -- APIs are different, that's not what I want.

This app is a client of https://www.v2ex.com, I have to start a server based on express to fetch APIs that do not supported CORS.

var app = require('express')();
var fetch = require('node-fetch');

app.get('/api/topics/hot.json', function (req, res) {
    fetch('https://www.v2ex.com/api/topics/hot.json').then(function (res) {
        return res.json();
    }).then(function (data) {
        res.json(data);
    });
});

app.get('/api/topics/latest.json', function (req, res) {
    fetch('https://www.v2ex.com/api/topics/latest.json').then(function (res) {
        return res.json();
    }).then(function (data) {
        res.json(data);
    });
});

Now let's build the main part. First create a src directory, we'll put all codes in. And we make an appointment that another dist directory includes the codes after built.

We have three simple pages: hot topic list, latest topic list and topic detail. We put three pieces of data in redux's store, so we need three reducers. Before that, we should define three action types representing loading hot list, latest list and detail in constant/action-types.js:

// constant/action-types.js
export const ACTION_LOAD_HOT = 'ACTION_LOAD_HOT';
export const ACTION_LOAD_LATEST = 'ACTION_LOAD_LATEST';
export const ACTION_TO_TOPIC = 'ACTION_TO_TOPIC';

Now the reducers:

// reducer/hot.js
import * as ACTION_TYPES from '../constant/action-types';

export const hot = (state = [], action) => {
    if (action.type === ACTION_TYPES.ACTION_LOAD_HOT) {
        return [...action.payload];
    } else {
        return state;
    }
};

// reducer/latest.js
import * as ACTION_TYPES from '../constant/action-types';

export const latest = (state = [], action) => {
    if (action.type === ACTION_TYPES.ACTION_LOAD_LATEST) {
        return [...action.payload];
    } else {
        return state;
    }
};

// reducer/topic.js
import * as ACTION_TYPES from '../constant/action-types';

export const topic = (state = {}, action) => {
    if (action.type === ACTION_TYPES.ACTION_TO_TOPIC) {
        return action.payload;
    } else {
        return state;
    }
};

// reducer/index.js
export * from './hot';
export * from './latest';
export * from './topic';

That's easy, right?

Now we define redux's actions:

// action/index.js
import * as ACTION_TYPES from '../constant/action-types';
import {push, goBack} from 'react-router-redux';
import fetchPonyfill from 'fetch-ponyfill';
import Promise from 'bluebird';

const {fetch} = fetchPonyfill(Promise);

export const loadHot = () => {
    return (dispatch, getState) => {
        const state = getState();
        if (state.hot && state.hot.length) {
            return;
        } else {
            fetch('/api/topics/hot.json', {
                mode: 'no-cors',
                headers: {
                    'Accept': 'application/json',
                    'Content-Type': 'application/json'
                }
            }).then(response => response.json()).then(hot => {
                dispatch({
                    type: ACTION_TYPES.ACTION_LOAD_HOT,
                    payload: hot
                });
            });
        }
    };
};

export const loadLatest = () => {
    return (dispatch, getState) => {
        const state = getState();
        if (state.latest && state.latest.length) {
            return;
        } else {
            fetch('/api/topics/latest.json', {
                mode: 'no-cors',
                headers: {
                    'Accept': 'application/json',
                    'Content-Type': 'application/json'
                }
            }).then(response => response.json()).then(latest => {
                dispatch({
                    type: ACTION_TYPES.ACTION_LOAD_LATEST,
                    payload: latest
                });
            });
        }
    };
};

export const toIndex = () => push('/');

export const toTopic = topic => {
    return dispatch => {
        dispatch({
            type: ACTION_TYPES.ACTION_TO_TOPIC,
            payload: topic
        });
        dispatch(push('/topic/' + topic.id));
    };
};

export const back = () => goBack();

Create store and history:

// config/index.js
import {hashHistory} from 'react-router';
import {createStore, combineReducers, applyMiddleware} from 'redux';
import * as reducers from '../reducer/';
import {syncHistoryWithStore, routerReducer, routerMiddleware} from 'react-router-redux';
import thunk from 'redux-thunk';

// Redux store
export const store = createStore(combineReducers({
    ...reducers,
    routing: routerReducer
}), applyMiddleware(thunk, routerMiddleware(hashHistory)));

export const history = syncHistoryWithStore(hashHistory, store);

routerMiddleware is necessary because we're using history's push()/goBack(). redux-thunk is required to support asynchronous action creators.

Then create an item of topic in list:

// component/topic.jsx
import React, {Component} from 'react';

export default class Topic extends Component {
    render() {
        const {member, title, onClick} = this.props;
        const {username, avatar_large} = member;
        return (<div className="topic-item f-f f-h f-m" onClick={()=>onClick(this.props)}>
            <div className="avatar-wrapper">
                <img className="avatar" src={avatar_large} alt={username}/>
            </div>
            <section className="info f-f f-v f-1">
                <span className="title f-1 f-f f-m"><strong>{title}</strong></span>
                <span className="desc f-1 f-f f-m">{username}</span>
            </section>
        </div>);
    }
}

A page wrapper:

// page/container.jsx
import React, {Component} from 'react';
import {Link} from 'react-router';

export default class Container extends Component {
    goback(e) {
        e.preventDefault();
        this.props.actions.back();
    }
    render() {
        const {current} = this.props;
        let latestClass = 'f-1 f-f f-c f-m ';
        let hotClass = 'f-1 f-f f-c f-m ';
        let backClass = 'back f-f f-c f-m ';

        if (current === '/') {
            latestClass += 'active';
            backClass += 'hide';
        } else if (current === '/hot') {
            hotClass += 'active';
            backClass += 'hide';
        }
        return (
        <div className="container">
            <header className="header f-f f-h">
                <a class={backClass} href="#" onClick={this.goback.bind(this)}>&lt;</a>
                <Link className={latestClass} to="/">Latest</Link>
                <Link className={hotClass} to="hot">Hot</Link>
            </header>
            <div className="content">
                {this.props.children}
            </div>
        </div>);
    }
}

The three pages:

// page/hot.jsx
import React, {Component} from 'react';
import Topic from '../component/topic.jsx';

export default class Hot extends Component {
    componentDidMount() {
        this.props.actions.loadHot();
    }
    onClick(topic) {
        this.props.actions.toTopic(topic);
    }
    render() {
         return (<div>{this.props.hot.map(topic => {
                    return <Topic {...topic} onClick={this.onClick.bind(this)} key={topic.id}/>;
                })}</div>);
    }
}

// page/latest.jsx
import React, {Component} from 'react';
import Topic from '../component/topic.jsx';

export default class Latest extends Component {
    componentDidMount() {
        this.props.actions.loadLatest();
    }
    onClick(topic) {
      this.props.actions.toTopic(topic);
    }
    render() {
        return (<div>{this.props.latest.map(topic => {
                    return <Topic {...topic} onClick={this.onClick.bind(this)} key={topic.id}/>;
                })}</div>);
    }
}

// page/topic.jsx
import React, {Component} from 'react';

export default class Topic extends Component {
    render() {
        const {topic} = this.props;
        const {content_rendered, title} = topic;

        if (!title) {
            this.props.actions.toIndex();
        }
        return (<div class="topic">
            <h1 class="title">{title}</h1>
            <article className="article" dangerouslySetInnerHTML={{__html:content_rendered}}></article>
        </div>);
    }
}

Finally, we start app:

// index.jsx
import {Router, Route, IndexRoute} from 'react-router';
import React from 'react';
import {render} from 'react-dom';
import Latest from './page/latest.jsx';
import Hot from './page/hot.jsx';
import Topic from './page/topic.jsx';
import Container from './page/container.jsx';
import {Provider, connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import * as actions from './action/';
import {store, history} from './config/';

function mapDispatchToProps(dispatch){
    return {
        actions: bindActionCreators(actions, dispatch)
    };
}

// Pages for routers
const ContainerPage = connect((state, ownProps) => ({current: ownProps.location.pathname}), mapDispatchToProps)(Container);
const LatestPage = connect(state => ({latest: state.latest}), mapDispatchToProps)(Latest);
const HotPage = connect(state => ({hot: state.hot}), mapDispatchToProps)(Hot);
const TopicPage = connect(state => ({topic: state.topic}), mapDispatchToProps)(Topic);

render((
<Provider store={store}>
      <Router history={history}>
          <Route path="/" component={ContainerPage}>
            <IndexRoute component={LatestPage}/>
            <Route path="latest" component={LatestPage}/>
            <Route path="hot" component={HotPage}/>
            <Route path="/topic/:topic_id" component={TopicPage}></Route>
          </Route>
      </Router>
</Provider>
), document.querySelector('#preact-root'));

See that, this is no preact at all! No hurry, I am gonna to solve that now.

I use panto to build the project. Panto acts as gulp, but with more features. It need a pantofile.js:

module.exports = panto => {
    require('load-panto-transformers')(panto);
    require('time-panto')(panto);

    panto.setOptions({
        cwd: __dirname,
        src: '.',
        output: 'dist'
    });

    panto.$('src/**/*.{js,jsx}').tag('JSX').read().babel({
        extend: '.babelrc',
        isSlient: false
    }).browserify({
        entry: 'src/index.jsx',
        bundle: 'bundle.js',
        process: {
            env: {
                NODE_ENV: 'production'
            }
        },
        aliases: {
            "react": "preact-compat",
            "react-dom": "preact-compat"
        }
    }).write();

    panto.$('src/*.html').tag('HTML').copy({flatten: true});
    panto.$('node_modules/normalize.css/normalize.css', true).tag('NORMALIZE').copy({
        flatten: true
    });
    panto.$('src/index.sass').tag('SASS').read().sass().write({
      destname: 'index.css'
    });
};

Yeah, I almost forgot, there should be a HTML and a Sass:

<!--index.html-->
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
        <title>V2EX</title>
        <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
        <meta name="revised" value="yanni4night,2016/09/07"/>
        <link rel="stylesheet" type="text/css" href="normalize.css">
        <link rel="stylesheet" type="text/css" href="index.css">
    </head>
    <body>
        <div id="preact-root"></div>
    </body>
    <script src="bundle.js"></script>
</html>
/*index.sass*/
.f-f {
  display: flex;
}
.f-h {
  flex-direction: row;
}
.f-v {
  flex-direction: column;
}
.f-m {
  align-items: center;
}
.f-c {
  justify-content: center;
}
.f-r {
  justify-content: flex-end;
}
.f-l {
  justify-content: flex-start;
}
.f-j {
  justify-content: space-between;
}
.f-1 {
  flex: 1;
}
.container {
    .header {
        a {
            &.back {
                width: 40px;
                overflow: hidden;
            }
            &.hide {
                width: 0;
            }
            &.active {
                text-decoration: underline;
                background-color:#302E2F;
            }
            transition: 0.5s all;
            text-align: center;
            line-height: 100%;
            text-decoration: none;
            color: #fff;
            font-weight: bolder;
        }
        background-color: #171A12;
        height: 50px;
    }
    .content {
        .topic-item {
            &:last-of-type {
                border-bottom: 0;
            }
            .avatar-wrapper {width: 60px;}
            img.avatar {
                display: block;
                width: 60px;
                height: 60px;
            }
            .info {
                .title {
                    strong {
                         white-space: nowrap;
                        text-overflow: ellipsis;
                        overflow: hidden;
                    }
                    font-size: 16px;
                }
                .desc {
                    font-size: 12px;
                }
                overflow: hidden;
                height: 60px;
                margin-left: 10px;
            }
            border-bottom: 1px solid #ccc;
            height: 60px;
            padding: 5px 10px;
        }
        .topic {
            .title {
                font-size: 16px;
            }
            .article {
                font-size: 12px;
                line-height: 1.5;
                overflow: auto;
            }
        }
        padding: 0 5px;
    }
}

Focus on pantofile.js:

aliases: {
            "react": "preact-compat",
            "react-dom": "preact-compat"
 }

With this section, we replace react with preact. If you want to switch to react, just remove this.

All codes are at https://github.com/yanni4night/v2ex-preact, preact works fine.

If you're looking for a substitute of react, preact is a good choice. But I suggest you to use preact-compat to make switching to react in case some issues that cannot be fixed.