Skip to content

Latest commit

 

History

History
165 lines (122 loc) · 6.5 KB

9-async-flow.litcoffee

File metadata and controls

165 lines (122 loc) · 6.5 KB

9. Async Data Flow

So far every action has been synchronous, which is an entirely unrealistic way to build a web app. We need to consider loading, failed loading, websocket-sourced updates, and other asynchronous pieces of state.

Redux is not async, because reducer functions are not async. That doesn't mean we can't build an async application with it — we just need to define loading, errors, etc. as pieces of our application state, and describe the data flow as an explicit series of actions.

This time we'll be building another (simpler) Twitter clone with loading and more loading (an approximation of "scroll to load more"). Loading states will be supported simply by adding loading and loading_more parameters to the collection state. The actual request and response will be represented by individual actions, tweets.loadtweets.loaded, and tweets.load_moretweets.loaded_more.

React = require 'preact'
Kefir = require 'kefir'
KefirBus = require 'kefir-bus'
moment = require 'moment'
React.__spread = Object.assign
{combineReducers, createStore, applyMiddleware} = require 'redux'

(Fake) loadable data

Insteading of introducing an API, the tweets will be generated and loading time will be faked with Kefir.later.

last_id = 0
last_time = new Date().getTime()

letters = 'abcdefghijklmnopqrstuvwxyz'
randomChoice = (l) -> l[Math.floor(Math.random() * l.length)]
randomLetter = -> randomChoice letters
randomWord = -> [0..Math.ceil(Math.random() * 10)].map(randomLetter).join('')
randomSentence = -> [0..Math.ceil(Math.random() * 10)].map(randomWord).join(' ')

fakeTweet = ->
    last_id += 1
    last_time -= Math.random() * 1000 * 60 * 10

    id = last_id
    time = last_time
    body = randomSentence()

    return {id, time, body}

indexById = (l) ->
    o = {}
    for i in l
        o[i.id] = i
    return o

loadTweets = (reload=false) ->
    if reload
        last_id = 0
        last_time = new Date().getTime()
    fake_tweets = [0...10].map fakeTweet
    fake_tweets = indexById fake_tweets
    Kefir.later Math.random() * 1000, fake_tweets

State and Store

To fit the loading state in there's a slight departure from previous collections: the items in the collection will be contained in an explicit items parameter, alongside the loading parameter(s).

initial_state =
    tweets:
        loading: true
        loading_more: false
        items: {}

create_collection_reducer = (collection_name) -> (state={}, action) ->
    switch action.type
        when "#{collection_name}.load"
            return Object.assign {}, state, {loading: true}
        when "#{collection_name}.load_more"
            return Object.assign {}, state, {loading_more: true}
        when "#{collection_name}.loaded"
            return Object.assign {}, state, {loading: false, items: action.items}
        when "#{collection_name}.loaded_more"
            return Object.assign {}, state, {loading_more: false, items: Object.assign {}, state.items, action.items}
    return state

combined_reducer = combineReducers
    tweets: create_collection_reducer 'tweets'

Middleware and the action stream

In order to support subscriptions to the actions themselves (rather than just resulting state changes, as store.subscribe offers) we'll create a Kefir stream called actions$ to pass actions through. A middleware function will be added to the Store to emit an action on that stream whenever store.dispatch(action) is called.

Middleware makes it possible to alter actions as they come through, but in this case it only hands the action to the actions$ stream, and passes through with next (similar to Express middleware).

actions$ = KefirBus()

actions$_middleware = -> (next) -> (action) ->
    actions$.emit action
    next(action)

store = createStore combined_reducer, initial_state, applyMiddleware(actions$_middleware)

Now with a stream of actions always available, we can trigger side effects when certain actions occur. For example, we'll have a reload button to reload the tweets. That button will dispatch the tweets.load action, but all that does is set the collection's loading state. The actual fetch will be triggered by a response to this action:

actions$
    .filter (action) -> action.type == 'tweets.load'
    .flatMap loadTweets.bind(null, true)
    .onValue (tweets) ->
        store.dispatch {type: 'tweets.loaded', items: tweets}

Similarly, when loading more tweets we'll trigger the load_more action and loaded_more after:

actions$
    .filter (action) -> action.type == 'tweets.load_more'
    .flatMap loadTweets.bind(null, false)
    .onValue (tweets) ->
        store.dispatch {type: 'tweets.loaded_more', items: tweets}

Initial load

To start things off we'll dispatch a load action:

store.dispatch {type: 'tweets.load'}

List and item components

The usual stateless greatness...

Tweets = ({tweets}) ->
    <div className='tweets'>
        {Object.entries(tweets).map ([tweet_id, tweet]) ->
            <Tweet tweet=tweet key=tweet_id />
        }
    </div>

Tweet = ({tweet}) ->
    <div className='tweet'>
        <span className='body'>{tweet.body}</span>
        <span className='time'>{moment(tweet.time).fromNow()}</span>
    </div>

App Component

Since there are no other pages in this demo, the main logic is built directly into the App component.

class App extends React.Component
    constructor: ->
        @state = store.getState()
        store.subscribe =>
            @setState store.getState()

    render: ->
        console.log '[App.render]', @state
        reload = -> store.dispatch {type: 'tweets.load'}
        loadMore = -> store.dispatch {type: 'tweets.load_more'}

        <div id='app'>
            <button onClick=reload>Reload</button>
            {if @state.tweets.loading
                <p>Loading...</p>
            else
                <Tweets tweets=@state.tweets.items />
            }
            {if !@state.tweets.loading
                if @state.tweets.loading_more
                    <p>Loading more...</p>
                else
                    <button onClick=loadMore>Load more</button>
            }
        </div>

React.render <App />, document.body

Next: