TIL: Action Cable + React
by Graham Marlow
Using Action Cable in React is surprisingly difficult. Every third-party package that I've come across that offers a custom hook makes some fundamental assumptions that only hold for simple applications. The vast majority of tutorials gloss over anything beyond the "log when event is received" boilerplate.
The principle of integrating Action Cable is easy, since it follows a well-known
subscribe/unsubscribe pattern with a useEffect
. It looks something like
this[1]:
import { createConsume } from '@rails/actioncable'
const MyComponent = () => {
const consumer = useMemo(
() => createConsumer('ws://localhost:3000/cable'),
[],
)
useEffect(() => {
const sub = consumer.subscriptions.create('AlertChannel', {
received(data) {
console.log(data)
},
})
return () => {
sub.unsubscribe()
}
}, [consumer])
return <div />
}
When all the React application is doing is logging some data, sure, easy-peasy. But when that component needs to access component state? Now we have a problem.
Let me demonstrate by introducing a stateful counter. It's a contrived example, but it gets the point across that accessing component state is probably useful for Websocket subscribers.
const MyComponent = () => {
const [count, setCount] = useState(0)
const consumer = useMemo(
() => createConsumer('ws://localhost:3000/cable'),
[],
)
useEffect(() => {
const sub = consumer.subscriptions.create('AlertChannel', {
received() {
console.log(count)
},
})
return () => {
sub.unsubscribe()
}
}, [consumer])
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>increment</button>
</div>
)
}
This example demonstrates the most obvious flaw: count
is missing in the
useEffect
dependencies, so no matter how many times the increment button is
clicked, the value logged will always be 0. We have to make the subscription
event handler aware that the count has changed by adding it as a dependency to
the effect.
useEffect(() => {
const sub = consumer.subscriptions.create('AlertChannel', {
received() {
console.log(count)
},
})
return () => {
sub.unsubscribe()
}
}, [consumer, count])
Now, theoretically this resolves our counter issue. And, for the most part, it
does. When we receive an Action Cable event from our server, the received
handler logs with the correct value of count
. However, in practice this code
has another bug: subscriptions are not properly cleaned up, so the client
responds to the Action Cable message many more times than expected. In my
testing, if I clicked the button 12 times quickly, I would see 6 console logs
when the Action Cable event is broadcast.
It seems that Action Cable is not particularly good about cleaning up subscriptions that have the same channel key. That is, when the increment button is clicked multiple times in succession (representing many state updates in our component), Action Cable does not do a good job ensuring that every connection is appropriately unsubscribed between renders. You will actually observe errors in the Rails console, indicating that it’s struggling to keep up:
Could not execute command from ({"command" => "unsubscribe", "identifier" =>
"{\"channel\":\"AlertChannel\"}"}) [RuntimeError - Unable to find subscription
with identifier: {"channel":"AlertChannel"}]
Digging into the Action Cable source code, it's made apparent that the Action Cable library uses a JSON-stringified representation of the channel name as an identifier when storing the subscriber. Here's the relevant code:
export default class Subscription {
constructor(consumer, params = {}, mixin) {
this.consumer = consumer
this.identifier = JSON.stringify(params)
extend(this, mixin)
}
// ...
I can only guess that there's a race condition somewhere in Action Cable involving identical subscription identifiers. I managed to locate a GitHub issue that tracks a similar problem and lends a little extra support to the theory.
One way to resolve this race condition is to simply include the count in the channel identifier, even if it's unused by the channel on the server. That way a unique identifier is created for every re-render caused by count:
useEffect(() => {
const sub = consumer.subscriptions.create(
{ channel: 'AlertChannel', count },
{
received() {
console.log(count)
},
},
)
return () => {
sub.unsubscribe()
}
}, [consumer, count])
This seems to get the job done. Each Websocket subscriber is given a unique key that can be easily located by the Action Cable library for removal. Note that this only works for serializable data.
I'll note that I also tried passing a reference (via
useRef
) for the Action Cable callbacks, hoping that a stable object reference might avoid the need for the extrauseEffect
dependency. However, when the Action Cable JS library creates new subscriptions, it creates an entirely new object, rendering the stable reference moot.
Anyway, all this to say: be careful when creating Action Cable subscriptions that rely on external state. Subscriptions created with the same key will likely not be cleaned up correctly.
Most of the time in React applications this doesn't matter that much, since we
can get by with a stable, memoized reference. useQueryClient
from
tanstack-query is a great example, since it allows us to invalidate our client
requests when an event is broadcast from the server:
// e.g. from react-query or tanstack-query
const queryClient = useQueryClient()
const consumer = useMemo(() => createConsumer('ws://localhost:3000/cable'), [])
useEffect(() => {
if (!consumer || !queryClient) {
return
}
const sub = consumer.subscriptions.create('AlertChannel', {
received() {
queryClient.invalidateQueries({
queryKey: ['alerts'],
})
},
})
return () => {
sub.unsubscribe()
}
}, [consumer, queryClient])
For other purposes, it's likely a good idea to pass serializable data to the Websocket channel parameters.
Note that I'm being careful to only create one consumer for a given component to avoid re-establishing a connection to the Websocket on every re-render. It's also likely that your consumer will need to live in a separate hook for authorization purposes. ↩︎