Building Real-Time Features with Rails ActionCable and React Native
Primary Keyword: Rails ActionCable React Native
Meta Description: Build real-time chat and live notifications with Rails ActionCable and React Native. Master WebSocket connections, message broadcasting, and client synchronization.
The Real-Time Expectation
Your user sends a message. They expect to see it instantly. They expect the recipient to see it instantly too.
You poll the server every 5 seconds. Messages arrive with a delay. Users think your app is broken. They leave.
This is the real-time problem. HTTP isn't built for it. You need WebSockets—persistent connections that push updates immediately.
Rails has ActionCable. React Native has WebSocket support. Together, they build real-time features that feel native.
Why this matters: We've shipped real-time features—chat, notifications, live dashboards—in production apps. The difference between polling (checking the server repeatedly) and WebSockets (server pushes updates) is night and day. Real-time isn't a luxury; it's table stakes for modern apps.
By the end of this guide, you'll understand:
- How WebSockets differ from HTTP (and why they matter).
- How to set up ActionCable in Rails for broadcasting messages.
- How to connect a React Native client to ActionCable channels.
- How to build a chat feature and live notifications.
- How to handle reconnections, errors, and offline states.
WebSockets vs. HTTP: The Fundamental Difference
HTTP: Request-Response
HTTP is stateless. The client asks, the server responds, the connection closes.
Client: "Give me the latest messages"
Server: "Here are 10 messages"
[Connection closes]
[5 seconds later]
Client: "Give me the latest messages again"
Server: "Here are 11 messages (1 new)"
[Connection closes]
This works for static content. It's terrible for real-time updates.
Problems with polling:
- Latency: Updates arrive every 5–10 seconds (the polling interval).
- Wasted requests: 90% of polls return "no new data."
- Server load: Every poll is a new HTTP request.
WebSockets: Persistent Connection
WebSockets keep the connection open. The server pushes updates when they happen.
Client: [Opens WebSocket connection]
Server: [Accepts connection]
[10 seconds later, a new message arrives]
Server: "New message: 'Hello!'"
Client: [Receives and displays immediately]
[Connection stays open]
Advantages:
- Instant updates: No polling delay.
- Efficient: No wasted requests.
- Bidirectional: Client and server can both send messages.
ActionCable: Rails' Real-Time Solution
ActionCable is Rails' built-in WebSocket framework. It integrates with Rails' existing infrastructure (ActiveRecord, authentication, etc.).
How ActionCable Works
- Channels: A channel is a logical stream (e.g.,
ChatChannel,NotificationChannel). - Broadcasting: The server broadcasts messages to a channel.
- Subscriptions: Clients subscribe to channels and receive broadcasts.
Setting Up ActionCable in Rails
1. Generate a channel:
rails generate channel Chat
This creates:
# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
def subscribed
# Called when the client subscribes
stream_from "chat_channel"
end
def unsubscribed
# Called when the client disconnects
end
def receive(data)
# Called when the client sends a message
ActionCable.server.broadcast("chat_channel", data)
end
end
2. Configure ActionCable:
In config/cable.yml:
development:
adapter: async
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: myapp_production
For production, use Redis. ActionCable needs a pub/sub backend to broadcast across multiple servers. Redis is the standard.
3. Authenticate the connection:
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
token = request.params[:token]
payload = JwtToken.decode(token)
if payload && (user = User.find_by(id: payload['user_id']))
user
else
reject_unauthorized_connection
end
end
end
end
This authenticates the WebSocket connection using a JWT token.
Building a Chat Feature
Let's build a simple chat room: users send messages, all connected users see them instantly.
Rails Backend
1. Create a Message model:
rails generate model Message user:references content:text
rails db:migrate
2. Create a Messages controller:
# app/controllers/api/v1/messages_controller.rb
class Api::V1::MessagesController < Api::V1::BaseController
def index
messages = Message.order(created_at: :desc).limit(50)
render json: messages.as_json(include: :user)
end
def create
message = current_user.messages.build(message_params)
if message.save
# Broadcast to all subscribers
ActionCable.server.broadcast(
"chat_channel",
{
id: message.id,
content: message.content,
user: {
id: current_user.id,
name: current_user.name
},
created_at: message.created_at
}
)
render json: message, status: :created
else
render_error('Failed to send message', :unprocessable_entity)
end
end
private
def message_params
params.require(:message).permit(:content)
end
end
3. Update the ChatChannel:
# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
def subscribed
stream_from "chat_channel"
end
def unsubscribed
# Cleanup when disconnected
end
end
React Native Client
1. Install ActionCable client:
npm install @rails/actioncable
2. Create a WebSocket utility:
// lib/cable.ts
import ActionCable from '@rails/actioncable';
import { getToken } from './auth';
let cable: ActionCable.Cable | null = null;
export async function getCable() {
if (!cable) {
const token = await getToken();
cable = ActionCable.createConsumer(
`ws://localhost:3000/cable?token=${token}`
);
}
return cable;
}
export function disconnectCable() {
if (cable) {
cable.disconnect();
cable = null;
}
}
3. Create a Chat screen:
// screens/ChatScreen.tsx
import { useEffect, useState, useRef } from 'react';
import { View, FlatList, TextInput, Button, Text } from 'react-native';
import { getCable } from '@/lib/cable';
import apiClient from '@/lib/api';
interface Message {
id: number;
content: string;
user: {
id: number;
name: string;
};
created_at: string;
}
export default function ChatScreen() {
const [messages, setMessages] = useState<Message[]>([]);
const [inputText, setInputText] = useState('');
const channelRef = useRef<any>(null);
useEffect(() => {
// Load initial messages
loadMessages();
// Subscribe to chat channel
subscribeToChat();
return () => {
// Unsubscribe when component unmounts
if (channelRef.current) {
channelRef.current.unsubscribe();
}
};
}, []);
const loadMessages = async () => {
try {
const response = await apiClient.get('/messages');
setMessages(response.data.reverse()); // Show oldest first
} catch (error) {
console.error('Failed to load messages', error);
}
};
const subscribeToChat = async () => {
const cable = await getCable();
channelRef.current = cable.subscriptions.create('ChatChannel', {
received(data: Message) {
// New message received
setMessages((prev) => [...prev, data]);
},
connected() {
console.log('Connected to chat');
},
disconnected() {
console.log('Disconnected from chat');
},
});
};
const sendMessage = async () => {
if (!inputText.trim()) return;
try {
await apiClient.post('/messages', {
message: { content: inputText },
});
setInputText('');
} catch (error) {
console.error('Failed to send message', error);
}
};
return (
<View style={{ flex: 1, padding: 20 }}>
<FlatList
data={messages}
renderItem={({ item }) => (
<View style={{ marginBottom: 10 }}>
<Text style={{ fontWeight: 'bold' }}>{item.user.name}</Text>
<Text>{item.content}</Text>
<Text style={{ fontSize: 10, color: '#888' }}>
{new Date(item.created_at).toLocaleTimeString()}
</Text>
</View>
)}
keyExtractor={(item) => item.id.toString()}
/>
<View style={{ flexDirection: 'row', marginTop: 10 }}>
<TextInput
style={{ flex: 1, borderWidth: 1, padding: 10 }}
value={inputText}
onChangeText={setInputText}
placeholder="Type a message..."
/>
<Button title="Send" onPress={sendMessage} />
</View>
</View>
);
}
How It Works
-
User opens the chat screen:
- Loads the last 50 messages via HTTP.
- Subscribes to the
ChatChannelvia WebSocket.
-
User sends a message:
- Sends a POST request to
/messages. - Rails saves the message and broadcasts it to all subscribers.
- Sends a POST request to
-
All connected clients receive the message:
- ActionCable pushes the message through the WebSocket.
- The
received()callback adds it to the local state. - The UI updates instantly.
Building Live Notifications
Notifications are simpler: the server pushes, the client displays.
Rails Backend
1. Create a NotificationChannel:
# app/channels/notification_channel.rb
class NotificationChannel < ApplicationCable::Channel
def subscribed
# Subscribe to user-specific notifications
stream_for current_user
end
end
2. Broadcast a notification:
# Anywhere in your Rails code
NotificationChannel.broadcast_to(
user,
{
title: "New message",
body: "You have a new message from Alice",
data: { message_id: 123 }
}
)
React Native Client
1. Subscribe to notifications:
// hooks/useNotifications.ts
import { useEffect, useState } from 'react';
import { getCable } from '@/lib/cable';
interface Notification {
title: string;
body: string;
data?: any;
}
export function useNotifications() {
const [notifications, setNotifications] = useState<Notification[]>([]);
useEffect(() => {
let channel: any;
const subscribe = async () => {
const cable = await getCable();
channel = cable.subscriptions.create('NotificationChannel', {
received(data: Notification) {
setNotifications((prev) => [...prev, data]);
// Optionally show a local notification
showLocalNotification(data);
},
});
};
subscribe();
return () => {
if (channel) channel.unsubscribe();
};
}, []);
return notifications;
}
function showLocalNotification(notification: Notification) {
// Use expo-notifications or react-native-push-notification
console.log('Notification:', notification.title);
}
2. Use in a component:
// screens/HomeScreen.tsx
import { useNotifications } from '@/hooks/useNotifications';
export default function HomeScreen() {
const notifications = useNotifications();
return (
<View>
<Text>Notifications: {notifications.length}</Text>
{notifications.map((notif, i) => (
<Text key={i}>{notif.title}: {notif.body}</Text>
))}
</View>
);
}
Handling Reconnections and Errors
WebSockets can disconnect. Networks fail. Your app needs to handle this.
Automatic Reconnection
ActionCable handles reconnection automatically. But you should show the user when they're disconnected.
const subscribeToChat = async () => {
const cable = await getCable();
channelRef.current = cable.subscriptions.create('ChatChannel', {
received(data: Message) {
setMessages((prev) => [...prev, data]);
},
connected() {
console.log('Connected to chat');
setConnectionStatus('connected');
},
disconnected() {
console.log('Disconnected from chat');
setConnectionStatus('disconnected');
},
});
};
Display the status:
{connectionStatus === 'disconnected' && (
<Text style={{ color: 'red' }}>Reconnecting...</Text>
)}
Queuing Messages While Offline
If the connection is lost, queue messages locally and send them when reconnected.
const sendMessage = async () => {
if (connectionStatus === 'disconnected') {
// Queue message locally
queueMessage(inputText);
setInputText('');
return;
}
try {
await apiClient.post('/messages', {
message: { content: inputText },
});
setInputText('');
} catch (error) {
// Queue if request fails
queueMessage(inputText);
}
};
// When reconnected, send queued messages
useEffect(() => {
if (connectionStatus === 'connected') {
sendQueuedMessages();
}
}, [connectionStatus]);
Scaling ActionCable
ActionCable scales horizontally with Redis. Each Rails server broadcasts to Redis, and Redis pushes to all connected clients.
Production Setup
1. Use Redis:
# config/cable.yml
production:
adapter: redis
url: <%= ENV['REDIS_URL'] %>
2. Deploy multiple Rails servers:
Each server handles WebSocket connections. Redis coordinates broadcasts.
3. Monitor connections:
ActionCable exposes metrics. Use tools like Scout or DataDog to monitor:
- Number of active connections.
- Messages per second.
- Redis pub/sub latency.
Common Pitfalls
1. Not Authenticating WebSocket Connections
Always authenticate the WebSocket. Otherwise, anyone can connect and listen to broadcasts.
2. Broadcasting Sensitive Data
Don't broadcast passwords, tokens, or private user data. Only broadcast what the client needs.
3. Not Handling Reconnections
Networks fail. Test your app with airplane mode toggled on/off. Does it reconnect? Does it queue messages?
4. Forgetting to Unsubscribe
If a component unmounts, unsubscribe from the channel. Otherwise, you'll have memory leaks.
return () => {
if (channelRef.current) {
channelRef.current.unsubscribe();
}
};
Bringing It All Together
Real-time features—chat, notifications, live updates—aren't optional anymore. Users expect them.
Rails ActionCable + React Native makes this straightforward:
- Rails broadcasts messages through ActionCable channels.
- React Native subscribes to those channels via WebSocket.
- Updates push instantly to all connected clients.
The pattern works for chat, notifications, live dashboards, collaborative editing, and more.
Start with a simple chat. Add notifications. Then experiment with live data (stock prices, game scores, order status).
Real-time isn't magic. It's WebSockets, Redis, and good state management. You already know enough to build it.
Have you built real-time features? What worked? What broke? Share on Twitter or LinkedIn—we'd love to hear your experience.