Skip to content

Commit

Permalink
Fix initialisation makes too many rerenders (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
FlorianCousin authored Jul 28, 2024
1 parent 8f58108 commit 1fa9324
Show file tree
Hide file tree
Showing 8 changed files with 153 additions and 73 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ To receive an event means to listen to a localStorage change on the specified ke

It is not recommended to use the shared state in a production usage because

- performance has not been measured (especially when there are more than 2 tabs),
- performance has not been measured,
- there is no unit test.

## Links
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@florian-cousin/use-tabs-state",
"version": "1.0.2",
"version": "1.1.0",
"description": "Hook for React state that is shared through all tabs",
"main": "dist/useTabsState.js",
"types": "dist/useTabsState.d.ts",
Expand Down
27 changes: 27 additions & 0 deletions src/eventsTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export enum EventType {
ASK_FOR_INITIALISATION = "ASK_FOR_INITIALISATION",
DATA_FOR_INITIALISATION = "DATA_FOR_INITIALISATION",
DATA_UPDATE = "DATA_UPDATE",
}

export const allEventsTypes: EventType[] = [
EventType.DATA_UPDATE,
EventType.DATA_FOR_INITIALISATION,
EventType.ASK_FOR_INITIALISATION,
];

function getSuffix(messageType: EventType): string {
switch (messageType) {
case EventType.ASK_FOR_INITIALISATION:
return "ask-for-initialisation";
case EventType.DATA_FOR_INITIALISATION:
return "data_initialisation";
case EventType.DATA_UPDATE:
return "data_update";
}
}

export function computeStorageKeyForEvent(key: string, eventType: EventType) {
const suffix: string = getSuffix(eventType);
return `${key}-${suffix}`;
}
35 changes: 35 additions & 0 deletions src/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { computeStorageKeyForEvent, EventType } from "./eventsTypes";

export type Listener<Value> = (value: Value) => void;
type StorageListener = Listener<StorageEvent>;

function wrapListenerForKey<Value>(storageKey: string, listener: Listener<Value>): StorageListener {
return ({ key, newValue }: StorageEvent) => {
const eventIsOnThisState: boolean = key === storageKey && newValue !== null;
if (eventIsOnThisState) {
const newValueParsed: Value = JSON.parse(newValue!);
listener(newValueParsed);
}
};
}

export function addListenerOnStorage<Value>(
key: string,
messageType: EventType,
listener: Listener<Value>
): StorageListener {
const storageKey: string = computeStorageKeyForEvent(key, messageType);
const wrappedListener = wrapListenerForKey<Value>(storageKey, listener);
window.addEventListener("storage", wrappedListener);
return wrappedListener;
}

export function removeListenerOnStorage(storageListener: StorageListener): void {
window.removeEventListener("storage", storageListener);
}

export function notify<Value>(key: string, messageType: EventType, value: Value): void {
const storageKey: string = computeStorageKeyForEvent(key, messageType);
localStorage.setItem(storageKey, JSON.stringify(value));
localStorage.removeItem(storageKey);
}
72 changes: 72 additions & 0 deletions src/useAllEventsSubscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { DependencyList, MutableRefObject, useLayoutEffect, useRef } from "react";
import { addListenerOnStorage, Listener, notify, removeListenerOnStorage } from "./messages";
import { SetState } from "./useTabsState";
import { allEventsTypes, EventType } from "./eventsTypes";

interface ActionOnEventParams<State> {
key: string;
state: State;
setState: SetState<State>;
isAlreadyInitialisedRef: MutableRefObject<boolean>;
}

function notifyOwnDataForInitialisation<State>({
key,
state,
isAlreadyInitialisedRef,
}: ActionOnEventParams<State>): [Listener<State>, DependencyList] {
const listener = () => {
isAlreadyInitialisedRef.current = true;
notify(key, EventType.DATA_FOR_INITIALISATION, state);
};

const dependencies = [state];
return [listener, dependencies];
}

function initialiseOwnStateIfNotAlreadyInitialised<State>({
setState,
isAlreadyInitialisedRef,
}: ActionOnEventParams<State>): [Listener<State>, DependencyList] {
const listener = (eventData: State) => {
if (!isAlreadyInitialisedRef.current) {
setState(eventData);
isAlreadyInitialisedRef.current = true;
}
};

const dependencies = [setState, isAlreadyInitialisedRef.current];
return [listener, dependencies];
}

function getActionOnEvent<State>(
eventTypeRecieved: EventType
): (actionOnEventParams: ActionOnEventParams<State>) => [Listener<State>, DependencyList] {
switch (eventTypeRecieved) {
case EventType.ASK_FOR_INITIALISATION:
return notifyOwnDataForInitialisation;
case EventType.DATA_FOR_INITIALISATION:
return initialiseOwnStateIfNotAlreadyInitialised;
case EventType.DATA_UPDATE:
return ({ setState }) => [setState, [setState]];
}
}

export function useAllEventsSubscription<State>(key: string, state: State, setState: SetState<State>) {
const isAlreadyInitialisedRef: MutableRefObject<boolean> = useRef(false);

const actionOnEventParams: ActionOnEventParams<State> = { key, state, setState, isAlreadyInitialisedRef };

allEventsTypes.forEach(messageType => registerActionForMessage(actionOnEventParams, messageType));
}

function registerActionForMessage<State>(
actionOnEventParams: ActionOnEventParams<State>,
messageType: EventType
): void {
const [listener, dependencies] = getActionOnEvent<State>(messageType)(actionOnEventParams);
useLayoutEffect(() => {
const registeredListener = addListenerOnStorage(actionOnEventParams.key, messageType, listener);
return () => removeListenerOnStorage(registeredListener);
}, dependencies);
}
79 changes: 15 additions & 64 deletions src/useTabsState.ts
Original file line number Diff line number Diff line change
@@ -1,85 +1,36 @@
import { Dispatch, SetStateAction, useLayoutEffect, useState } from "react";
import { notify } from "./messages";
import { useAllEventsSubscription } from "./useAllEventsSubscription";
import { EventType } from "./eventsTypes";

type Listener<Value> = (value: Value) => void;
type StorageListener = Listener<StorageEvent>;

export type InitialState<State> = State | (() => State);
export type SetState<State> = Dispatch<SetStateAction<State>>;
export type UseStateReturn<State> = [State, SetState<State>];
export type InitialState<State> = State | (() => State);

export function useTabsState<State>(initialState: InitialState<State>, storageKey: string): UseStateReturn<State> {
const [state, setState]: UseStateReturn<State> = useState<State>(initialState);

useRegisterInitStorageListener<State>(storageKey, state);

useRegisterStateStorageListener<State>(storageKey, setState);

useNotifyInitialisationForOtherTabs(storageKey);
export function useTabsState<State>(initialState: InitialState<State>, key: string): UseStateReturn<State> {
const useStateReturn: UseStateReturn<State> = useState<State>(initialState);
const [state, setState]: UseStateReturn<State> = useStateReturn;

const useStateReturn: UseStateReturn<State> = [state, setState];
const setStateInStorage = buildSetStateInStorage(useStateReturn, storageKey);
useAllEventsSubscription(key, state, setState);

return [state, setStateInStorage];
}
useNotifyInitialisationForOtherTabs(key);

function useRegisterInitStorageListener<State>(storageKey: string, state: State): void {
useLayoutEffect(() => {
const initStorageKey: string = buildInitStorageKey(storageKey);
const onInitStorageChange = () => {
notifyWithLocalStorage(storageKey, state);
};
const registeredListener = addListenerOnStorageKey(initStorageKey, onInitStorageChange);
return () => window.removeEventListener("storage", registeredListener);
}, [state]);
}
const setStateAndNotify = buildSetStateAndNotify(useStateReturn, key);

function useRegisterStateStorageListener<State>(storageKey: string, setState: SetState<State>) {
useLayoutEffect(() => {
const registeredListener = addListenerOnStorageKey(storageKey, setState);
return () => window.removeEventListener("storage", registeredListener);
}, []);
return [state, setStateAndNotify];
}

function useNotifyInitialisationForOtherTabs(storageKey: string): void {
function useNotifyInitialisationForOtherTabs(key: string): void {
useLayoutEffect(() => {
const initStorageKey: string = buildInitStorageKey(storageKey);
notifyWithLocalStorage(initStorageKey, "storage initialisation");
notify(key, EventType.ASK_FOR_INITIALISATION, "storage initialisation");
}, []);
}

function buildSetStateInStorage<State>(
[previousState, setState]: UseStateReturn<State>,
storageKey: string
): SetState<State> {
function buildSetStateAndNotify<State>([previousState, setState]: UseStateReturn<State>, key: string): SetState<State> {
return (stateUpdater: SetStateAction<State>) => {
const stateUpdaterIsFunction = stateUpdater instanceof Function;
const newState: State = stateUpdaterIsFunction ? stateUpdater(previousState) : stateUpdater;
notifyWithLocalStorage(storageKey, newState);
notify(key, EventType.DATA_UPDATE, newState);
setState(stateUpdater);
};
}

function notifyWithLocalStorage<Value>(storageKey: string, value: Value): void {
localStorage.setItem(storageKey, JSON.stringify(value));
localStorage.removeItem(storageKey);
}

function addListenerOnStorageKey<Value>(storageKey: string, listener: Listener<Value>): StorageListener {
const wrappedListener = wrapListenerForKey<Value>(storageKey, listener);
window.addEventListener("storage", wrappedListener);
return wrappedListener;
}

function wrapListenerForKey<Value>(storageKey: string, listener: Listener<Value>): StorageListener {
return ({ key, newValue }: StorageEvent) => {
const eventIsOnThisState: boolean = key === storageKey && newValue !== null;
if (eventIsOnThisState) {
const newValueParsed: Value = JSON.parse(newValue!);
listener(newValueParsed);
}
};
}

function buildInitStorageKey(storageKey: string): string {
return `${storageKey}-init`;
}
5 changes: 0 additions & 5 deletions src/useTabsState_ADR.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,3 @@ When a new tab wants to use the state, it emits a state initialisation on the sp
If no other tab is using the shared state, then nothing occurs.
If another tab is using the shared state, then it receives the initialisation event and sends back the current state it holds.
Finally, the initialising tab receives the shared state and updates its local one.

## Drawbacks

Let n be the number of tabs that are using the shared state.
When another tab appears and uses the shared state, then every existing tab sends its current state, so every existing tab rerender n-1 times.

0 comments on commit 1fa9324

Please sign in to comment.