How to achieve a resilient no-js app using only JavaScript
Switch branches/tags
Nothing to show
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Failed to load latest commit information.
css
js
lib
plugin
test
.gitignore
.travis.yml
CONTRIBUTING.md
Gruntfile.js
LICENSE
README.md
REVEAL_README.md
architecture.svg
bower.json
demo.html
index.html
package.json
photo.png
yarn.lock

README.md

A no-js app with React and Redux


No-JS ?


  • Write a web app in full JavaScript
  • Works without JavaScript.

note:

An app that will be :

  • Using different server side rendering techniques
  • JS server + client = DRY

Samuel Bouchet

Freelance - JavaScript developer and architect

samuel-bouchet.fr

@Lythom


Overview of the architecture



Why

Client side rich application


Improve User Experience (dynamic features, fast, offline)


Why

Server side rendering

note: mode single page application full client


Improve User Experience (First display is earlier) Only if (server time to render) < (client time to download + time to init)


SEO (everybody says that so it must be true)


(+) Reduce the server network load (less requests if data is pre-fetched and cached server side) note: allowed by, but not inheritent to, ssr


(+) Improve the user experience (provide the service server side if the client cannot)

  • Before load
  • Compatibility fallback
  • People that disable js on purpose
  • Robots that can perform actions for people (assistance, delegated use, integration tests) note: for older browers (ie6, ie7, ie8, olders android, windows phones) You might even want to ship an es6 app

i18n

  • Force you to create proper forms / html components / links
  • Accessibility tools can perform actions by querying the server

integration ex: superagent (Cf. conf Alvin Berthelot)


(+) Resilience

  • Support server overload / fails by serving a static prerendered (from CDN, nginx, etc.)
  • Support client fail (inclusiveness, client app js error)
  • Both fails = still a static page that indicate that interactions are currently unavailable.
    • Best experience is top-notch and Worst experience is decent

Experience

  • Shipped an app in production that showcase a google map + timed events

note:

  • Confidential Article written in 2016-01 without the static part

Demo


Routing

  • with js
  • without js
  • with js then a crash
  • without server with js
  • without server without js

Counter

  • with js
  • without js
  • with js then a crash
  • without server with js
  • without server without js

Map

  • with js
  • without js
  • with js then a crash
  • without server with js
  • without server without js

Building the app


Starting point


"create-react-app" = no SSR, hacky to add


"nwb" = no SSR, hacky to add


"kyt"

  • = ok for an universal starter
  • /!\ Too much magic = lost of control, could do what I want

"next.js" = Didn't try (discovered too late) but could do = Add a complex frame around the projet


"enter your boilerplate here" = Does too many things = Miss some points, and because it does so many things the complexity is a real obstacle


From scratch


The most basic server-side rendering


Install NodeJS latest stable

http://nodejs.org

Install express

npm install --save-dev express

+ src/server.js
+ src/client/index.js
+ package.json

package.json :

"scripts": {
  "start" : "node src/server.js"
},

client/index.js :

document.getElementById('root').innerHTML =
 "I'm client rendered ! with dynamic interactions !"

server.js :

res.send(
  `<div id="root">
  I'm server rendered ! with static interactions !
  </div>
  <script src="index.js" />`
)

Getting serious


Server side counter


npm install --save shortid cookie-parser

server.js :

app.use(cookieParser())

app.get('*', function(req, res) {
  getSession(req.cookies.session)
  .then(sessionData => {
    // init store
    const store = createInitialStore(sessionData)

    if (!counter.selectors.isInitialized(store.getState().counter)) {
      store.dispatch(counter.actions.initCounterStart())
    }
    
    // save session state
    const sessionid = req.cookies.session || shortid.generate()
    setSession(sessionid, store.getState())
    
    // return response
    res.cookie('session', sessionid, { 
      maxAge : SESSION_DURATION, 
      secure : process.env.NODE_ENV !== 'development', 
      httpOnly : true
    })
    res.send(render())
  })
})

note:

Interesting example because force to use a session system. Ex: Redis, PouchDB


app/component/CounterDemo.js :

class CounterDemo extends React.PureComponent {

  componentDidMount() {
    this.interval = setInterval(this.forceUpdate.bind(this), 1000)
  }

  componentWillUnmount() {
    clearInterval(this.interval)
  }

  render() {/**/}
}
function mapStateToProps(state) {/**/}
export default connect(mapStateToProps)(CounterDemo)

class CounterDemo extends React.PureComponent {
  componentDidMount() { /**/}
  componentWillUnmount() { /**/}
  render() {
    const intro = 'I\'m server rendered with react'
    if (this.props.hasStaticInteractions) 
      return <div>{intro}. Service is currently down, try again later !</div>

    const count = this.props.isCounterInitialized ? 
      Math.floor((Date.now() - this.props.counterStart) / 1000) : ''
      
    return (
        <div>
          {intro}. 
          Counting: {count} (test reload number : 40) 
          {this.props.hasServerInteractions === true && <Reload />} ! 
          with {this.props.interactions} interactions !
        </div>
    )
  }
}

function mapStateToProps(state) {/**/}
export default connect(mapStateToProps)(CounterDemo)

class CounterDemo extends React.PureComponent {
  componentDidMount() {/**/}
  componentWillUnmount() {/**/}
  render() {/**/}
}

function mapStateToProps(state) {
  return {
    counterStart          : counter.selectors.getCounterStart(state.counter),
    isCounterInitialized  : counter.selectors.isInitialized(state.counter),
    hasStaticInteractions : interactions.selectors.isStatic(state.interactions),
    hasServerInteractions : interactions.selectors.isServer(state.interactions),
    interactions          : interactions.selectors.getInteractions(state.interactions)
  }
}

export default connect(mapStateToProps)(CounterDemo)

server side openstreetmap


+ MapPage.js            -- Open Layer dynamic loading
+ MapList.js            -- Side list
+ MyMap.js              -- Layout / coordinator
+ olHelpers.js          -- manipulation of the Open Layer API
+ OLMap.js              -- Wrapper of the Open Layer imperative plugin
+ SelectionPopup.js     -- Description of the selection
+ umapDataSelectors.js  -- manipulation of the raw data

MapPage.js :

class MapPage extends React.PureComponent {
  constructor() {/**/}
  componentDidMount() {/**/}
  render() {
    return <div>
      <Helmet>
        <link rel="stylesheet" href="https://openlayers.org/en/v4.1.1/css/ol.css" type="text/css"/>
        <script defer src="https://openlayers.org/en/v4.1.1/build/ol.js" type="text/javascript"/>
      </Helmet>
      <MyMap ol={this.state.openLayerLibrary} />
    </div>
  }
}

note:

injected in component to remove the "global" use


MapPage.js :

class MapPage extends React.PureComponent {
    constructor() {
      super()
      this.state = {
        openLayerLibrary : MapPage.openLayerLibrary
      }
    }
    
    componentDidMount() {
      if (this.state.openLayerLibrary === null) {
        whenAvailable('ol').then((ol) => {
          MapPage.openLayerLibrary = ol
          this.setState({ openLayerLibrary : ol })
        })
      }
    }
    render() {/**/}
}

note:

  • client side only
  • loaded on demand to lighten the initial build

MyMap.js

render() { return (
    <div>
      <FilterBar />
      <div className="pos-a">
        <OLMap umapData={this.props.data}
               ol={this.props.ol}
               popupContainer={this.state.popupContainer} />
        <SelectionPopup 
            umapData={this.props.data} 
            registerPopupContainer={this.registerPopupContainer} />
      </div>
      <MapList umapData={this.props.data} />
    </div>
)}
export default withData(dataURL.umapData)(MyMap)

note:

  • Removed CSS and stuff for clarity
  • SelectionPopup is aside OLMap for server rendering, but used by OLMap. => popupContainer reference
  • withData is universal, auto fetching OR from cache
    • client side : fetch on mount if the data is not loaded
    • server side : specific instruction to hydrate cache
  • Possible to connect each of the 3 components instead of MyMap. But withData too raw.

OLMap.js :

componentDidUpdate(prevProps, prevState) {
    if (prevProps.umapData !== this.props.umapData
      || prevProps.filter !== this.props.filter
      || prevProps.ol !== this.props.ol
      || prevState.mapContainer !== this.state.mapContainer) {
      this.updateMap()
    }
    if (prevProps.selectedFeature !== this.props.selectedFeature) {
      this.selectFeature(getFeatureByName(
        this.props.umapData.layers, 
        this.props.selectedFeature)
      )
    }
}

note:

  • declarative to imperative programming. Listen to changes and react.

OLMap.js :

import generateStaticMapURL from 'server/generateStaticMapURL'
//
render() {
    const { umapData, filter } = this.props
    
    let mapURL = 'assets/mapPlaceholder.png'
    if (generateStaticMapURL && filter !== '') {
      mapURL = generateStaticMapURL(umapData.layers, filter)
    }
    
    return (
      <div ref={this.setMapContainer}>
        <img src={mapURL} height="auto"/>
      </div>
    )
}

note:

  • server/generateStaticMapURL will be null client side (webpack alias)
    • because fallback server side only
    • remove unused dependencies by the client
    • use mapbox service, public token currently not hidden. TODO: proxy server side that append token.

MapList.js (button) :

<form method="GET" action="">
    <button type={isStatic ? 'button' : 'submit'} 
            name="filter" value={feature.properties.name} 
            onClick={(e) => setFeature(e, feature.properties.name)}>
      <strong>{feature.properties.name}</strong><br/>
      {feature.properties.street} - {feature.properties.city}
      {isStatic && <div className="pt-1 d-n d-b:parent-focus">
        {feature.properties.description}
      </div>}
    </button>
</form>

note:

  • button :
    • handle a click client side
    • submit a filter with dynamic interactions
    • only take focus with static interactions
    • Have a description on focus with static interactions

SelectionPopup.js :

setPopupContainer(container) {
  const register = this.props.registerPopupContainer
  if (register) register(container)
}
render() { 
    const selection = getSelection(this.props)
    return (
    <div className="pos-a bgc-1 p-1 bd-2" ref={this.setPopupContainer}
       style={{
           display     : selection != null ? 'block' : 'none',
           left        : `calc(50% - 160px)`,
           bottom      : this.props.isDynamic ? 42 : `calc(50% + 44px)`,
           width       : 320,
           borderColor : getColor(this.props) }}>
    <strong>{selection && selection.name}</strong>
    <div>{selection && selection.description}</div>
    </div>
)}

note:

  • setPopupContainer : way to transmit a dom reference to a brother (via parent)
  • isDynamic : container = OL map
  • isServer or isStatic : container = parent div the surround the map
  • CSS design atomic

going further


  • Make the app work fully offline
    • real-time synchronization of Redux store using PouchDB
    • tabs and devices states are synchronized (not necessary navigation)
    • the app can go offline then synchronise back with custom conflict resolution where needed

  • Make a generic server that automatically performs actions when client side js is disabled
    • Act as a front-end proxy
    • Ie. would use a pattern matching on url to dispatch any action

  • Using static server by default for best performances and fallback on interactive server when needed.

References