Add useNitroEventState / useMessageEventState hooks (proposal #1)

Introduce the building block for reducing the state-from-event
boilerplate that pervades the codebase:

  // Before
  const [foo, setFoo] = useState(initial);
  useNitroEvent(SOME_EVENT, e => setFoo(e.payload));

  // After
  const foo = useNitroEventState(SOME_EVENT, e => e.payload, initial);

Implementation notes:
- src/hooks/events/useNitroEventState.ts wraps useNitroEvent so the
  selector closure can use up-to-date surrounding values (captured in
  a ref refreshed in commit via useLayoutEffect) without forcing a
  re-subscription on every render. Listener is registered once and
  always reads the latest selector.
- src/hooks/events/useMessageEventState.ts is the mirror for
  useMessageEvent (server message channel — request/response composers
  and push parsers).
- Both pass the new react-hooks v7 rules cleanly (in particular the
  strict react-hooks/refs that forbids ref mutation during render).
- Re-exported from src/hooks/events/index.ts so callers reach them
  via the existing `from '../../hooks'` import path.

Pilot adoption (1 site) to demonstrate the pattern:
- src/components/catalog/views/targeted-offer/OfferView.tsx:
  the offer state was a clean derive-from-event case
  (setOffer(parser.data) on TargetedOfferEvent, no other writes).
  Replaced with a single useMessageEventState call using the optional
  chain `evt.getParser()?.data ?? null` as selector. Removes the
  useState pair and the explicit subscription block.

Honest scope note:
A broader sweep is intentionally NOT done. Most existing event
subscriptions in this codebase are multi-state updates, state
machines, conditional filters ("skip if not my id"), or have side
effects mixed in (notifications, redirects). Forcing those into
useNitroEventState would lose information and risk regressions in
behavior the lint won't catch. Adoption should happen organically
when contributors see a clean derive-from-event case, not as a
mechanical replace-all.

https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
This commit is contained in:
simoleo89
2026-05-11 16:31:52 +00:00
parent 5d8717dedb
commit 22a44d18b0
4 changed files with 84 additions and 11 deletions
@@ -1,24 +1,19 @@
import { GetTargetedOfferComposer, TargetedOfferData, TargetedOfferEvent } from '@nitrots/nitro-renderer';
import { useEffect, useState } from 'react';
import { SendMessageComposer } from '../../../../api';
import { useMessageEvent } from '../../../../hooks';
import { useMessageEventState } from '../../../../hooks';
import { OfferBubbleView } from './OfferBubbleView';
import { OfferWindowView } from './OfferWindowView';
export const OfferView = () =>
{
const [ offer, setOffer ] = useState<TargetedOfferData>(null);
const offer = useMessageEventState<TargetedOfferEvent, TargetedOfferData>(
TargetedOfferEvent,
evt => evt.getParser()?.data ?? null,
null
);
const [ opened, setOpened ] = useState<boolean>(false);
useMessageEvent<TargetedOfferEvent>(TargetedOfferEvent, evt =>
{
let parser = evt.getParser();
if(!parser) return;
setOffer(parser.data);
});
useEffect(() =>
{
SendMessageComposer(new GetTargetedOfferComposer());
+2
View File
@@ -1,4 +1,6 @@
export * from './useEventDispatcher';
export * from './useMessageEvent';
export * from './useMessageEventState';
export * from './useNitroEvent';
export * from './useNitroEventState';
export * from './useUiEvent';
+34
View File
@@ -0,0 +1,34 @@
import { IMessageEvent, MessageEvent } from '@nitrots/nitro-renderer';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useMessageEvent } from './useMessageEvent';
/**
* Subscribe to a server message event and expose the latest derived
* value as React state. Mirror of useNitroEventState for the Nitro
* communication channel (request/response composers, push parsers).
*
* const data = useMessageEventState(SomeParser, e => e.getParser().data, null);
*/
export const useMessageEventState = <T extends IMessageEvent, S>(
eventType: typeof MessageEvent,
selector: (event: T) => S,
initial: S | (() => S)
): S =>
{
const [ value, setValue ] = useState<S>(initial);
const selectorRef = useRef(selector);
useLayoutEffect(() =>
{
selectorRef.current = selector;
});
const handler = useCallback((event: T) =>
{
setValue(selectorRef.current(event));
}, []);
useMessageEvent<T>(eventType, handler);
return value;
};
+42
View File
@@ -0,0 +1,42 @@
import { NitroEvent } from '@nitrots/nitro-renderer';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useNitroEvent } from './useNitroEvent';
/**
* Subscribe to a Nitro renderer event and expose the latest derived value
* as React state. Replaces the boilerplate pattern:
*
* const [foo, setFoo] = useState(initial);
* useNitroEvent(EVENT, e => setFoo(selector(e)));
*
* with:
*
* const foo = useNitroEventState(EVENT, selector, initial);
*
* The selector closure is captured in a ref refreshed in commit, so
* a new selector identity per render does not re-subscribe the listener.
*/
export const useNitroEventState = <T extends NitroEvent, S>(
type: string | string[],
selector: (event: T) => S,
initial: S | (() => S),
enabled: boolean = true
): S =>
{
const [ value, setValue ] = useState<S>(initial);
const selectorRef = useRef(selector);
useLayoutEffect(() =>
{
selectorRef.current = selector;
});
const handler = useCallback((event: T) =>
{
setValue(selectorRef.current(event));
}, []);
useNitroEvent<T>(type, handler, enabled);
return value;
};