Menu hamburger icon

Creating a Cordova Hybrid App with React, Redux and Webpack

Cordova app using Onsen UI, React and Redux

In this article we will take a look at how to create a Cordova hybrid app using Onsen UI and React. We will create a full-fledged weather application that uses Redux to manage the state and Onsen UI to create a beautiful UI.

In a previous article we learned how to use Redux to manage the React application state. We have also learned how to use the Redux DevTools and hot reloading to easily debug React applications. This article will build on the things we learned in the previous articles so if you haven’t already, please read those first.

The source code for this example is available in this GitHub repository and you can try out the demo here.

Running the app with Cordova CLI

The repository is a Cordova project so you can try running it on your device. There is a hook that runs the Webpack build every time cordova prepare is executed so there is no need to build manually every time you deploy.

To run the app with Cordova you first need to clone the repo and install the dependencies.

git clone git@github.com:argelius/react-onsenui-redux-weather.git
cd react-onsenui-redux-weather/
npm install

Now, assuming that you have Cordova installed, you can add a platform and run the app:

cordova platform add android
cordova run android

You can of course also run the app on iOS or any other platform that Cordova supports.

Code structure

We will structure our code in the same way as the previous article. Even if the app we made previously was very simple, the structure we used scales very well and can be used for applications with a large number of components and containers.

The project will have the following directory structure:

  • index.js - The entry point of the app. Imports the root component and renders the app.
  • reducers/ - Reducer functions. A reducer is a pure function that takes the current state and an action as arguments and returns the next state. This is the only way to update the Redux store.
  • actions/ - Contains action creators. An action creator is a function that returns an action that can be dispatched to update the Redux store state tree.
  • api/ - This directory contains code that wraps the weather API. For this app I have used the Open Weather Map API which is a great free alternative.
  • components/ - This directory contains dumb components. A dumb component is a component that is just renders props to a view but doesn’t perform any particular actions.
  • containers/ - A container is a component that is connected to the Redux store. It can dispatch actions and access the state.

I will not go into detail on what the difference between the components and containers are, or how reducers and action creators are used since we have already introduced these concepts in the previous blog post.

As a build tool I am using Webpack. This is just my personal preference, this code will work with Browserify as well.

If you are interested you can take a look at the Webpack config used in the project by clicking this link. This config includes React Hot Loader which really helps speed up development. I have also made a Webpack configuration for production which is used when I deploy to GitHub pages or build as a Cordova app.

Action creators

This app fetches data from a remote API, so we need a way to dispatch an action asynchronously. This is different from the previous app we made where every action was synchronous. To enable asynchronous actions we are using the Redux Thunk library.

The library is a middleware so it can be added using the applyMiddleware function when creating the Redux store using the createStore function:

import {createStore, applyMiddleware} from 'redux';
import thunk from 'redux-thunk';

import rootReducer from './reducers';

const initialState = {};

const store = createStore(
  rootReducer,
  initialState,
  /**
   * Apply the thunk middleware.
   */
  applyMiddleware(thunk)
);

This middleware enables action creators to return a function instead of an action. This can be used to delay dispatching an action. The obvious use case is to delay an action until an asynchronous API call has been resolved (or rejected if something went wrong).

In this app there is an action creator called fetchWeather which uses the Thunk middleware:

export const requestWeather = (id) => ({
  type: REQUEST_WEATHER,
  id
});

export const receiveWeather = (id, data) => ({
  type: RECEIVE_WEATHER,
  id,
  ...data
});

export const setFetchError = id => ({
  type: SET_FETCH_ERROR,
  id
});

export const fetchWeather = (id) => {
  /*
   * This function requests and receives the
   * weather data asynchronously.
   */
  return (dispatch, getState) => {
    const name = getState().locations[id].name;

    dispatch(requestWeather(id));
    queryWeather(name)
      .catch(() => dispatch(setFetchError(id)))
      .then((data) => dispatch(receiveWeather(id, data)));
  };
};

As we can see the fetchWeather action creator runs the queryWeather function to fetch the current weather data from the Open Weather Map API.

The rest of the action creators are very simple. This is the whole actions/index.js file that exports all action creators:

import {v4 as generateId} from 'node-uuid';

import {queryWeather} from '../api';

export const ADD_LOCATION = 'ADD_LOCATION';
export const REMOVE_LOCATION = 'REMOVE_LOCATION';
export const SELECT_LOCATION = 'SELECT_LOCATION';

export const REQUEST_WEATHER = 'REQUEST_WEATHER';
export const RECEIVE_WEATHER = 'RECEIVE_WEATHER';
export const SET_FETCH_ERROR = 'SET_FETCH_ERROR';

export const OPEN_DIALOG = 'OPEN_DIALOG';
export const CLOSE_DIALOG = 'CLOSE_DIALOG';

export const addLocation = (name) => ({
  type: ADD_LOCATION,
  id: generateId(),
  name
});

export const removeLocation = id => ({
  type: REMOVE_LOCATION,
  id
});

export const selectLocation = id => ({
  type: SELECT_LOCATION,
  id
});

export const requestWeather = (id) => ({
  type: REQUEST_WEATHER,
  id
});

export const receiveWeather = (id, data) => ({
  type: RECEIVE_WEATHER,
  id,
  ...data
});

export const setFetchError = id => ({
  type: SET_FETCH_ERROR,
  id
});

export const fetchWeather = (id) => {
  /*
   * This function requests and receives the
   * weather data asynchronously.
   */
  return (dispatch, getState) => {
    const name = getState().locations[id].name;

    dispatch(requestWeather(id));
    queryWeather(name)
      .catch(() => dispatch(setFetchError(id)))
      .then((data) => dispatch(receiveWeather(id, data)));
  };
};

export const addLocationAndFetchWeather = name => {
  return (dispatch, getState) => {
    const id = dispatch(addLocation(name)).id;
    dispatch(fetchWeather(id));
  };
};

export const openDialog = () => ({
  type: OPEN_DIALOG
});

export const closeDialog = () => ({
  type: CLOSE_DIALOG
});

As we can see there are actions for adding and removing locations, fetching and receiving weather data as well as showing and hiding the dialog.

Reducers

The corresponding reducers can be found in the reducers/ directory. The app uses the combineReducers function to combine the following reducers:

  • locations
  • selectedLocation
  • dialog
import {combineReducers} from 'redux';
import selectedLocation from './selectedLocation';
import locations from './locations';
import dialog from './dialog';

const todoApp = combineReducers({
  locations,
  selectedLocation,
  dialog
});

export default todoApp;

The combineReducers function takes a list of reducers and combines them into a new reducer. The resulting state tree will have the following structure:

{
  locations: [...],
  selectedLocation: SOME_ID,
  dialog: false
}

NOTE: When using combineReducers it is important to not use the same action ID twice. For larger app it is a good idea to use a naming scheme where every child reducer has its own namespace to avoid collisions.

The code of the selectedLocation and dialog reducers is very simple.

The selectedLocation reducer just handles a single string, which is the ID of the location that is currently selected:

const selectedLocation = (state = null, action) => {
  switch (action.type) {
    case 'SELECT_LOCATION':
      return action.id;
    default:
      return state;
  }
};

export default selectedLocation;

Similarly, the dialog reducer just handles a single boolean value which represents whether the dialog is shown or hidden:

import {OPEN_DIALOG, CLOSE_DIALOG} from '../actions';

const dialog = (state = {open: false}, action) => {
  switch (action.type) {
    case OPEN_DIALOG:
      return {
        open: true
      };
    case CLOSE_DIALOG:
      return {
        open: false
      };
    default:
      return state;
  }
};
export default dialog;

Finally, the locations reducer is pretty complicated since it can handle five different action IDs:

import {
  ADD_LOCATION,
  REMOVE_LOCATION,
  REQUEST_WEATHER,
  RECEIVE_WEATHER,
  SET_FETCH_ERROR
} from '../actions';

const initialState = {
  isFetching: false,
  isInvalid: false,
  temperature: 0,
  icon: -1,
  humidity: 0
};

const location = (state = initialState, action) => {
  switch (action.type) {
    case ADD_LOCATION:
      return {
        id: action.id,
        name: action.name,
        ...state
      };
    case REQUEST_WEATHER:
      return {
        ...state,
        isFetching: true,
        isInvalid: false
      };
    case RECEIVE_WEATHER:
      return {
        ...state,
        isFetching: false,
        isInvalid: false,
        ...action
      };
    case SET_FETCH_ERROR:
      return {
        ...state,
        isFetching: false,
        isInvalid: true
      };
    default:
      return state;
  }
};

const locations = (state = {}, action) => {
  switch (action.type) {
    case ADD_LOCATION:
      return {
        ...state,
        [action.id]: location(undefined, action)
      };
    case REMOVE_LOCATION:
      const {...rest} = state;
      delete rest[action.id];
      return rest;
    case SET_FETCH_ERROR:
    case REQUEST_WEATHER:
    case RECEIVE_WEATHER:
      return {
        ...state,
        [action.id]: location({...state[action.id]}, action)
      };
    default:
      return state;
  }
};

export default locations;

As you can see in the code above, I have split the reducer into two since some of the actions act on the list (ADD_LOCATION and REMOVE_LOCATION) while some (SET_FETCH_ERROR, REQUEST_WEATHER, RECEIVE_WEATHER) act on a single item of the list.

API calls

In the fetchWeather action creator in the code above, there is a call to a function called queryWeather that returns a Promise. The code of this function can be found in api/index.js and it’s basically just a wrapper that uses the new fetch API to request the latest weather data for a location:

import fetch from 'isomorphic-fetch';
import Promise from 'promise';

const API_KEY = '5a043a1bd95bf3ee500eb89de107b41e';
const API_URL = 'http://api.openweathermap.org/data/2.5';

const kelvinToCelsius = (kelvin) => kelvin - 273.15;

const round = (value, decimals = 1) => {
  const x = Math.pow(10, decimals);
  return Math.round(x * value) / x;
};

const apiCall = (url) => {
  return fetch(url)
    .then(response => {
      if (response.status >= 400) {
        return Promise.reject('Invalid response');
      }

      return response.json();
    })
    .then(json => {
      if (parseInt(json.cod) !== 200) {
        return Promise.reject('Invalid response');
      }

      return json;
    });
};

export const queryWeather = (city) => {
  let data;

  return apiCall(`${API_URL}/weather?q=${city.trim()}&appid=${API_KEY}`)
    .then(json => {
      data = {
        temperature: round(kelvinToCelsius(json.main.temp), 0),
        humidity: json.main.humidity,
        icon: json.weather[0].id,
        name: json.name,
        country: json.sys.country.toLowerCase()
      };

      return apiCall(`${API_URL}/forecast/daily?id=${json.id}&cnt=5&appid=${API_KEY}`);
    })
    .then(json => {
      return {
        ...data,
        forecast: json.list.map((d) => ({
          weekday: (new Date(d.dt * 1000)).getDay(),
          icon: d.weather[0].id,
          maxTemp: round(kelvinToCelsius(d.temp.max), 0),
          minTemp: round(kelvinToCelsius(d.temp.min), 0)
        }))
      };
    });
};

The code actually makes two requests since it first needs to get the current weather and then get the 5-day forecast. I am using the fetch function instead of XHR since it is a lot simpler, especially for fetching JSON data and since it already returns a Promise there is no need to create a Promise object.

Components

In this section I will take a look at some of the components used in the application. Some of them are very simple and require no explanation while some of them are more complex.

We will first take a look at the App component. This is the root component of the app. It renders into a Navigator component which is an Onsen UI component that enables stack based navigation. Stack navigation is a common pattern in mobile apps where a new page is pushed on top of the previous page.

import React from 'react';

import {
  Navigator
} from 'react-onsenui';

import MainPage from './MainPage';

/**
 * This function takes a `route` object as
 * an argument and uses it to render
 * the corresponding page.
 */
const renderPage = (route, navigator) => (
  <route.component key={route.key} navigator={navigator} />
);

const App = () => (
  <Navigator
    renderPage={renderPage}
    initialRoute={{component: MainPage, key: 'MAIN_PAGE'}}
  />
);

export default App;

The Navigator component implements a very powerful API. It manages a stack of route objects which are rendered into pages using the renderPage prop. The route objects can be structured in any way the developer wants, in this case they have the following structure:

{
  component: MyComponent,
  key: A_UNIQUE_KEY
}

In order to push a new page on top of the stack, the following code is used:

navigator.pushPage({component: MyComponent, key: 'my-component'});

NOTE: The renderPage function must return an Onsen UI Page component.

The following dumb components are also used in the app but they require no further explanation. Please take a look at the source code for more information.

  • Forecast - Renders the 5-day forecast on the bottom of the details page.
  • MainPage - Renders the list of locations and the controls for adding new locations.
  • NavBar - Renders the toolbar on the top of the pages.
  • WeatherIcon - Renders the icon based on the current weather.

If you take a look at any of these components you will see that the layout and design is created entirely using inline styles. The only CSS used in the app is the Onsen UI default CSS. I think it’s very convenient to put the styles in the same file as the JavaScript code, since it makes the component self-contained.

Containers

As mentioned earlier, containers are React components that are connected to the Redux store. They have access to the state tree (or part of it) and can dispatch events. To take a look at all the containers used in this app, please follow this link.

The containers used in this app are:

  • LocationList - Renders a list of locations from the current state.
  • Location - Renders an item in the list of locations. When clicked it will navigate to a details page.
  • AddLocation - When clicked it opens a dialog where the user can add a new location. Renders as a Material Design floating action button on Android and a normal button on iOS.
  • AddLocationDialog - A dialog that can be used to add a new location to the list.
  • WeatherPage - A details page for a location that shows the current weather as well as a 5-day forecast.

In this article we will only examine the LocationList and Location. If anything is unclear please inspect the code of the other containers as well.

So let’s start by looking at the LocationList container. As the name implies it renders a list of locations.

import React from 'react';
import {connect} from 'react-redux';

import {List} from 'react-onsenui';

import Location from './Location';

const LocationList = ({locations, navigator}) => (
  <List
    dataSource={Object.keys(locations).map((key) => locations[key])}
    renderRow={(location) =>
      <Location
        key={location.id}
        navigator={navigator}
        {...location}
      />
    }
  />
);

const mapStateToProps = (state) => ({
  locations: state.locations
});

export default connect(mapStateToProps)(LocationList);

It uses the connect function from the React Redux library to map the locations state to the locations prop so it can be accessed from the component. To render the list the List component from Onsen UI is used. The List component needs to define two props: dataSource and renderRow which are used to render the list:

<List
  dataSource={[1, 2, 3, 4]}
  renderRow={(n) => <ListItem>{n}</ListItem>}
>

In the renderRow prop above we see that the Location container is rendered. This component represents an item in the location list:

import React from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';

import {ListItem, Icon} from 'react-onsenui';

/**
 * Onsen UI has some helper functions that can be
 * used to see what platform is running.
 */
import {platform} from 'onsenui';

import * as Actions from '../actions';
import WeatherPage from './WeatherPage';
import WeatherIcon from '../components/WeatherIcon';
import {weatherCodeToColor} from '../util';

/**
 * We use different styles depending on the
 * platform since Material Design lists
 * and iOS lists are quite different.
 */
const styles = {
  weatherIcon: {
    color: '#fff',
    textAlign: 'center',
    width: platform.isAndroid() ? '36px' : '30px',
    height: platform.isAndroid() ? '36px' : '30px',
    lineHeight: platform.isAndroid() ? '36px' : '30px',
    borderRadius: '6px',
    fontSize: platform.isAndroid() ? '16px' : '14px'
  },
  buttons: {
    fontSize: '20px',
    color: '#cacaca'
  },
  refreshButton: {
    margin: '0 25px 0 0'
  },
  removeButton: {
    margin: '0 10px 0 0'
  }
};

const Location = ({
  id,
  name,
  temperature,
  humidity,
  icon,
  country,
  isFetching,
  isInvalid,
  navigator,
  actions
}) => {
  let subtitle;

  /**
   * If there was an error or
   * if the data is currently being
   * fetched we display an appropriate
   * message to the user.
   */
  if (isInvalid) {
    subtitle = (
      <span style={{color: 'red'}}>
        Unable to fetch data!
      </span>
    );
  } else if (isFetching) {
    subtitle = (
      <span>Fetching data...</span>
    );
  } else {
    subtitle = (
      <span>
        {temperature}&deg;C&nbsp;
        {humidity}%
      </span>
    );
  }

  const weatherColor = weatherCodeToColor(icon);

  return (
    <ListItem onClick={() => {
      /**
       * Select the location and navigate
       * to the details page if the item
       * is clicked.
       */
      actions.selectLocation(id);
      navigator.pushPage({component: WeatherPage});
    }} tappable>
      <div className='left'>
        <div style={{...styles.weatherIcon, backgroundColor: weatherColor}}>
          {icon < 0 ? '?' : <WeatherIcon icon={icon} />}
        </div>
      </div>
      <div className='center'>
        <div className='list__item__title'>
          {name}
        </div>
        <div className='list__item__subtitle'>
          {subtitle}
        </div>
      </div>
      <div className='right' style={styles.buttons}>
        <div onClick={(e) => {
          /**
           * Refresh the weather if this icon
           * is pressed.
           */
          e.stopPropagation();
          actions.fetchWeather(id);
        }}>
          <Icon icon='refresh' className='weather-button' style={styles.refreshButton} className={isFetching ? 'spin-animation' : ''} />
        </div>
        <div onClick={(e) => {
          /**
           * Remove the entry if the
           * trashcan icon is pressed.
           */
          e.stopPropagation();
          actions.removeLocation(id);
        }}>
          <Icon icon='trash' className='weather-button' style={styles.removeButton} />
        </div>
      </div>
    </ListItem>
  );
};

const mapDispatchToProps = (dispatch) => {
  return {
    actions: bindActionCreators(Actions, dispatch)
  };
};

export default connect(
  undefined,
  mapDispatchToProps
)(Location);

I will not discuss the code of the rest of the containers since it wouldn’t add anything to the article. They basically follow the same structure as the two we have discussed. Please take a look at the source code if you are interested.

Conclusion

I hope this article has been interesting. The goal was to show how a quite complicated app can be created easily using React and Redux with the Onsen UI components. If you are interested in creating your own apps using these technologies you can use this project as a starting point.

If you want to get started quickly using Onsen UI and React, I recommend you to try out our new CLI tool. It uses Cordova and Webpack under the hood but hides a lot of the complexity so you can get started making hybrid apps in no time.

Installing it is as easy as doing:

[sudo] npm install -g monaca

This is a new tool so we are really looking forward to your feedback!

If you have any questions about using Onsen UI or any of our other tools, please feel free to ask in the forum. If you like Onsen UI, please give us a star on GitHub.

Comments