Skip to main content

Component Instance State

같은 atom state 정의를 여러 UI 인스턴스가 독립적으로 쓰도록 instanceId별 atom family로 분리하는 패턴이다.

핵심은 one state definition, many isolated instances다.

1. 모듈 코드

  • src/modules/ui/utilities/state/component-state/utils/createComponentInstanceContext.ts: subtree에서 component instance id를 전달할 Context를 만든다.
  • src/modules/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow.ts: props로 받은 id를 우선하고, 없으면 context id를 사용한다.
  • src/modules/ui/utilities/state/jotai/utils/createAtomComponentState.ts: instanceId마다 별도 Jotai atom을 캐싱해서 반환한다.
// filepath: src/modules/ui/utilities/state/component-state/utils/createComponentInstanceContext.ts
import { type ComponentInstanceStateContext } from '@/ui/utilities/state/component-state/types/ComponentInstanceStateContext';
import { type ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey';
import { createContext } from 'react';

export const createComponentInstanceContext = <
T extends ComponentStateKey = ComponentStateKey,
>(
initialValue?: T,
) => {
return createContext<T | null>(
initialValue ?? null,
) as ComponentInstanceStateContext<T>;
};

// filepath: src/modules/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow.ts
import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
import { type ComponentInstanceStateContext } from '@/ui/utilities/state/component-state/types/ComponentInstanceStateContext';
import { isNonEmptyString } from '@sniptt/guards';

export const useAvailableComponentInstanceIdOrThrow = <
T extends { instanceId: string },
>(
Context: ComponentInstanceStateContext<T>,
instanceIdFromProps?: string,
): string => {
const instanceStateContext = useComponentInstanceStateContext(Context);
const instanceIdFromContext = instanceStateContext?.instanceId;

if (isNonEmptyString(instanceIdFromProps)) {
return instanceIdFromProps;
} else if (isNonEmptyString(instanceIdFromContext)) {
return instanceIdFromContext;
} else {
throw new Error(
'Instance id is not provided and cannot be found in context.',
);
}
};

// filepath: src/modules/ui/utilities/state/jotai/utils/createAtomComponentState.ts
import { type ComponentInstanceStateContext } from '@/ui/utilities/state/component-state/types/ComponentInstanceStateContext';
import { type ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey';
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
import { type ComponentState } from '@/ui/utilities/state/jotai/types/ComponentState';
import { atom } from 'jotai';
import { isDefined } from 'twenty-shared/utils';

export const createAtomComponentState = <ValueType>({
key,
defaultValue,
componentInstanceContext,
}: {
key: string;
defaultValue: ValueType;
componentInstanceContext: ComponentInstanceStateContext<any> | null;
}): ComponentState<ValueType> => {
if (isDefined(componentInstanceContext)) {
globalComponentInstanceContextMap.set(key, componentInstanceContext);
}

const atomCache = new Map<
string,
ReturnType<ComponentState<ValueType>['atomFamily']>
>();

const familyFunction = ({
instanceId,
}: ComponentStateKey): ReturnType<
ComponentState<ValueType>['atomFamily']
> => {
const existing = atomCache.get(instanceId);

if (existing !== undefined) {
return existing;
}

const baseAtom = atom(defaultValue);
baseAtom.debugLabel = `${key}__${instanceId}`;
atomCache.set(instanceId, baseAtom);

return baseAtom;
};

return {
type: 'ComponentState',
key,
atomFamily: familyFunction,
};
};

2. 사용 예제

import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';

export const PanelComponentInstanceContext = createComponentInstanceContext();

export const panelIsOpenComponentState = createAtomComponentState<boolean>({
key: 'panelIsOpenComponentState',
defaultValue: false,
componentInstanceContext: PanelComponentInstanceContext,
});

export const Panel = ({
panelInstanceId,
}: {
panelInstanceId: string;
}) => {
return (
<PanelComponentInstanceContext.Provider
value={{ instanceId: panelInstanceId }}
>
<PanelContent />
</PanelComponentInstanceContext.Provider>
);
};

export const PanelContent = () => {
const [isOpen, setIsOpen] = useAtomComponentState(panelIsOpenComponentState);

return (
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? 'Close' : 'Open'}
</button>
);
};