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;
Top comments (0)