loading...

Using Action Cable with React Native

tegandbiscuits profile image Tegan Rauh ・7 min read

One of the changes with Rails 6 was to make Action Cable work with webworkers.. Consequently, this also makes it possible to now use the Action Cable client javascript with React Native because it now depends on the DOM less.

That said, at the time of writing, there isn't a solid guarantee that it will continue to work.
https://github.com/rails/rails/pull/36652#issuecomment-510623557

That also said, if it does stop working, it'll most likely be caught during compilation or a very apparent error when testing the update.

So dust off your generic demo application hats, because I'm going to be showing how you can build a chat app using rails and React Native (and hopefully extend that knowledge to real apps). I'm going to assume a knowledge of javascript and Rails (or that you'll look up anything you don't know).

Rails Project Generation

Skip to the React Native App Generation section if you've already got a Rails project and just need to see how to hook Action Cable up to it.

To make sure we're all on the same page I'm going to quickly go through setting up a Rails app. It's just going to be a very minimal application, and it should be easy enough to work this into an existing app.

I'm using rails 6.0.3.2, and ruby 2.6.3, but the versions shouldn't matter too much.

I've generated the application with

rails new ChatApp --skip-sprockets

Browser Version

To make sure things are getting set up correctly, I'm going to make a really simple browser version of the chat app. This isn't really necessary, it's just for demonstration (and if you're closely following along might be useful to find out why something isn't working).

I've created a simple controller and layout like so

# app/controllers/home_controller.rb

class HomeController < ApplicationController
  def index; end
end
# config/routes.rb

Rails.application.routes.draw do
  root to: 'home#index'
end
<!-- app/views/home/index.html.erb -->

<h1>Chat App</h1>

<form id="message-form">
  <input type="text" name="message" id="message">
  <input type="submit" value="Send">
</form>

<hr>

<div id="messages">
</div>
// Added to app/javascript/packs/application.js

document.addEventListener('DOMContentLoaded', () => {
  const form = document.querySelector('#message-form');

  const formSubmitted = (e) => {
    e.preventDefault();
    const { value } = e.target.querySelector('#message');
    console.log('i will send', value);
    e.target.reset();
  };

  form.addEventListener('submit', formSubmitted);
});

This is all pretty straight forward. At this point when you visit the home page, you'll see a very bare page with a form. If you submit the form, the console will log i will send X.

Adding Action Cable

Action Cable is included by default when running rails new. If you don't have anything in app/channels, then you'll need to set it up first. The Rails Guide should be enough to go off of.

Now we're going to create a channel by running this command.

rails g channel Chat

This will create app/channels/chat_channel.rb and app/javascript/channels/chat_channel.js.

After making some modifications, here are the final files I ended up with.

# app/channels/chat_channel.rb

class ChatChannel < ApplicationCable::Channel
  def subscribed
    stream_from('main_room')
    content = {
      key: SecureRandom.hex(5),
      message: 'someone has arrived'
    }
    ActionCable.server.broadcast('main_room', content: content)
  end

  def receive(data)
    content = data.merge(key: SecureRandom.hex(5))
    ActionCable.server.broadcast('main_room', content: content)
  end
end

Let's quickly break this down a bit.

In ChatChannel#subscribed, we're going to create a generic message when someone connects, then send it off to everyone in the main_room room. For key I'm just using a random unique value. This is purely just for React to have a key attribute; if you're saving data and have an ID or have another unique attribute then this isn't necessary.

ChatChannel#recieve will take in the data from the client websocket, then add a key to act as an ID and spit it back out to the clients (including the one that initially sent it).

// app/javascript/channels/chat_channel.js

import consumer from './consumer';

const ChatChannel = consumer.subscriptions.create({ channel: 'ChatChannel', room: 'main_room' }, {
  received(data) {
    const messagesContainer = document.querySelector('#messages');
    const message = document.createElement('div');

    message.innerHTML = `
      <p>${data.content.message}</p>
    `;

    messagesContainer.prepend(message);
  },
});

export default ChatChannel;

In this file we're just connecting to the channel, and setting up a method that will run when new data is broadcasted. All this function does is add new message to the messages container.

Now we just need to send data instead of log it by using ChatChannel.send. Here is the final application.js I ended up with.

// app/javascript/packs/application.js

require('@rails/ujs').start();
require('turbolinks').start();
require('@rails/activestorage').start();
require('channels');

import ChatChannel from '../channels/chat_channel'; // new

document.addEventListener('DOMContentLoaded', () => {
  const form = document.querySelector('#message-form');

  // modified
  const formSubmitted = (e) => {
    e.preventDefault();
    const { value } = e.target.querySelector('#message');
    ChatChannel.send({ message: value }); // new
    e.target.reset();
  };

  form.addEventListener('submit', formSubmitted);
});

Assuming everything is working, the message will be broadcasted to all the connected clients and added to the page. If you wanted, you could test this by opening up the site in multiple tabs.

Sometimes the message 'someone has arrived' won't show on the just connected client. If it doesn't show, try reloading a few times, or using multiple tabs

React Native App Generation

I'm going to use Expo for this project.

I'm using Node version 12.18.1 and Expo 3.23.3.

Generate a new Expo project with

expo init ChatAppClient --template blank

For the this guide, I'm going to use the iOS simulator. You should be able to use whichever platform you want.

Running yarn ios should eventually pop you into the iPhone simulator with a minimal app.

Base Layout

For demonstration purposes I'm going to do everything in App.js.

Here is what I'm starting out with. It doesn't make any calls to the server yet, just generally sets everything up.

// App.js

import React, { useState } from 'react';
import {
  StyleSheet,
  Text,
  View,
  TextInput,
  KeyboardAvoidingView,
  FlatList,
} from 'react-native';
import Constants from 'expo-constants';

const styles = StyleSheet.create({
  container: {
    paddingTop: Constants.statusBarHeight,
    height: '100%',
  },
  messages: {
    flex: 1,
  },
  message: {
    borderColor: 'gray',
    borderBottomWidth: 1,
    borderTopWidth: 1,
    padding: 8,
  },
  form: {
    backgroundColor: '#eee',
    paddingHorizontal: 10,
    paddingTop: 10,
    paddingBottom: 75,
  },
  input: {
    height: 40,
    borderColor: 'gray',
    borderWidth: 1,
    backgroundColor: 'white',
  },
});

const Message = ({ message }) => (
  <View style={styles.message}>
    <Text style={styles.message}>{message}</Text>
  </View>
);

const App = () => {
  const [value, setValue] = useState('');
  const [messages, setMessages] = useState([{ key: '1', message: 'hi' }]);

  const renderedItem = ({ item }) => (<Message message={item.message} key={item.key} />);
  const inputSubmitted = (event) => {
    const newMessage = event.nativeEvent.text;
    console.log('will send', newMessage);
    setValue('');
  };

  return (
    <KeyboardAvoidingView style={styles.container} behavior="height">
      <FlatList
        styles={styles.messages}
        data={messages}
        renderItem={renderedItem}
        keyExtractor={(item) => item.key}
      />

      <View style={styles.form}>
        <TextInput
          style={styles.input}
          onChangeText={text => setValue(text)}
          value={value}
          placeholder="Type a Message"
          onSubmitEditing={inputSubmitted}
        />
      </View>
    </KeyboardAvoidingView>
  );
};

export default App;

Hooking Up Action Cable

Most of this process is copying what would be done in the browser.

First we need to add the Action Cable package.

yarn add @rails/actioncable

Note: make sure you add @rails/actioncable instead of actioncable, otherwise you wont be using the Rails 6 version.

First let's create our consumer.

import { createConsumer } from '@rails/actioncable';

global.addEventListener = () => {};
global.removeEventListener = () => {};

const consumer = createConsumer('ws://localhost:5000/cable'); // the localhost url works on the iOS simulator, but will likely break on Android simulators and on actual devices.

We need to set global functions for addEventListener and removeEventListener because they're currently used in Action Cable to tell when the tab is in view. See this issue for more context.

If you want, you don't need to make these functions be empty. They just need to exist (and be functions) otherwise the code will blow up.

Another thing to point out is that we need to give createConsumer a URL to connect to. The protocol needs to be ws or wss otherwise, Action Cable will try to do stuff with the DOM. By default /cable is the path that Action Cable uses (you'll probably know if this isn't the case for you). When in doubt if you've got the right URL, just try it in the browser version, then you can see if it fails.

Sometimes simulators (in my experience particularly the Android simulator) doesn't treat localhost as the same localhost as your browser. There are ways around it, like using a specific IP address, or using a tool like ngrok, or just deploying your backend somewhere. If you need to, this also works with the browser version of Expo.

Next we need to join the channel and add incoming messages. That can be done by adding the following to the App component.

const chatChannel = useMemo(() => {
  return consumer.subscriptions.create({ channel: 'ChatChannel', room: 'main_room' }, {
    received(data) {
      setMessages(messages => messages.concat(data.content));
    },
  });
}, []);

useMemo will run the given callback whenever one of the values in the array change. In this case, we're not actually giving any values, so it will never change. Meaning we're connecting to the channel when the App component first gets rendered (or just use componentDidMount if you're working with a class component). The value of chatChannel is the same Subscription object like what gets exported by chat_channel.js in the browser version.

Now all that's left is to send the message in the inputSubmitted function. That can be done by modifying it to look like this.

const inputSubmitted = (event) => {
  const newMessage = event.nativeEvent.text;
  chatChannel.send({ message: newMessage }); // new
  setValue('');
};

Assuming everything is set up correctly (and an update hasn't gone out that breaks everything), you should be able to send messages between the app and browser version.

Here's the final App.js file I've ended up with:

// App.js

import React, { useState, useMemo } from 'react';
import {
  StyleSheet,
  Text,
  View,
  TextInput,
  KeyboardAvoidingView,
  FlatList,
} from 'react-native';
import Constants from 'expo-constants';
import { createConsumer } from '@rails/actioncable';

global.addEventListener = () => {};
global.removeEventListener = () => {};

const consumer = createConsumer('ws://localhost:5000/cable');

const styles = StyleSheet.create({
  container: {
    paddingTop: Constants.statusBarHeight,
    height: '100%',
  },
  messages: {
    flex: 1,
  },
  message: {
    borderColor: 'gray',
    borderBottomWidth: 1,
    borderTopWidth: 1,
    padding: 8,
  },
  form: {
    backgroundColor: '#eee',
    paddingHorizontal: 10,
    paddingTop: 10,
    paddingBottom: 75,
  },
  input: {
    height: 40,
    borderColor: 'gray',
    borderWidth: 1,
    backgroundColor: 'white',
  },
});

const Message = ({ message }) => (
  <View style={styles.message}>
    <Text style={styles.message}>{message}</Text>
  </View>
);

const App = () => {
  const [value, setValue] = useState('');
  const [messages, setMessages] = useState([]);
  const chatChannel = useMemo(() => {
    return consumer.subscriptions.create({ channel: 'ChatChannel', room: 'main_room' }, {
      received(data) {
        setMessages(messages => messages.concat(data.content));
      },
    });
  }, []);

  const renderedItem = ({ item }) => (<Message message={item.message} key={item.key} />);
  const inputSubmitted = (event) => {
    const newMessage = event.nativeEvent.text;
    chatChannel.send({ message: newMessage });
    setValue('');
  };

  return (
    <KeyboardAvoidingView style={styles.container} behavior="height">
      <FlatList
        styles={styles.messages}
        data={messages}
        renderItem={renderedItem}
        keyExtractor={(item) => item.key}
      />

      <View style={styles.form}>
        <TextInput
          style={styles.input}
          onChangeText={text => setValue(text)}
          value={value}
          placeholder="Type a Message"
          onSubmitEditing={inputSubmitted}
        />
      </View>
    </KeyboardAvoidingView>
  );
};

export default App;

Discussion

pic
Editor guide