Skip to content

No External Store Subscription

Suggestion
small-rules/no-external-store-subscription

Disallow subscribing to an external store in an effect.

Rationale

Ported from eslint-plugin-react-you-might-not-need-an-effect/no-external-store-subscription. The upstream rule points straight at the “Subscribing to an external store” section of the React docs, so that’s where the intent comes from.

By “external state” I mean state that React doesn’t own. You can read it and subscribe to it, but you can’t derive it from props or other state. On the web that’s browser APIs or your store. roblox-ts has no window or navigator, so in practice it’s stores: store.get() to read, store.subscribe() to listen, store.unsubscribe() to clean up. That’s why the examples here use a store instead of the online-status code from the React docs.

The rule doesn’t actually check that you subscribed to anything, though. It keys off the shape: the effect body synchronously writes state, and the cleanup references the same setter. That’s the subscribe/unsubscribe pair, but it also fires on effects that just reset state in their cleanup, with no store in sight. Treat the diagnostic as “this has the external-store shape” and check whether there’s a real store before reaching for useSyncExternalStore.

Diagnostic Messages

avoidExternalStoreSubscription
Avoid using an effect to subscribe to an external store. Instead, use "useSyncExternalStore" to manage "{{state}}".

Configuration

This rule accepts one options object after the severity.

environmentOptional

The React environment: 'roblox-ts' uses @rbxts/react, 'standard' uses react.

{
"jsPlugins": [
"@pobammer-ts/small-rules"
],
"rules": {
"small-rules/no-external-store-subscription": [
"error",
{
"environment": "roblox-ts"
}
]
}
}

Examples

Effect subscribes to an external store
import { useEffect, useState } from "@rbxts/react";
interface Store {
get: () => number;
subscribe: (callback: () => void) => void;
unsubscribe: (callback: () => void) => void;
}
export function useStoreValue(store: Store): number {
const [value, setValue] = useState(0);
useEffect(() => {
setValue(store.get());
function update(): void {
setValue(store.get());
}
store.subscribe(update);
return (): void => {
store.unsubscribe(update);
};
}, [store]);
return value;
}
Subscription managed with useSyncExternalStore
import { useSyncExternalStore } from "@rbxts/react";
interface Store {
get: () => number;
subscribe: (callback: () => void) => void;
unsubscribe: (callback: () => void) => void;
}
declare const store: Store;
function subscribe(callback: () => void): () => void {
store.subscribe(callback);
return (): void => {
store.unsubscribe(callback);
};
}
export function useStoreValue(): number {
return useSyncExternalStore(subscribe, (): number => store.get());
}