Global Context
전역적으로 관리되는 컨텍스트 입니다.
state 와 handler, 그리고 사용되는 web-storage 등의 값이 포함 되어 있습니다.
web storage
web-storage 는 자주 사용되는 api 중에 하나입니다.
하지만, localStorage 는 리엑트에서 다양한 불편함을 가지고 있는데, 아래와 같습니다.
데이터에 접근할 때 JSON.parse 와 stringfy 가 수반되어야 합니다.
데이터의 수정사항을 react 의 생명주기에서 감지 할 수 없습니다.(데이터 수정으로 리랜더링이 촉발되지 않습니다.)
위와 같은 불편함을 해결하고자 만들어진 hook 이 useSyncWebStorage 입니다.
전역 컨텍스트에서는 위 훅을 사용해 아래 처럼 사용하고 있습니다.
사용 방법
// src/utils/web-storage/todo.ts
import { SyncedStorageFactory } from './helper/synced-storage-factory';
export type TodoType = {
text: string;
};
export const {
connector: todoConnector, //
storage: todoStorage,
} = SyncedStorageFactory.createLocal<TodoType[]>('todo');
// src/contexts/global/hooks/useWebStorage.ts
import { useSyncWebStorage } from '@/hooks/useSyncWebStorage';
import { todoConnector } from '@/utils/web-storage/todo';
export const useWebStorage = () => {
const todoList = useSyncWebStorage(todoConnector);
return { token, todoList };
};
// src/contexts/global/useGlobalStoreContext.ts
import { useWebStorage } from './hooks/useWebStorage';
const useGlobalStore = () => {
const { state, dispatch } = useGlobalState();
const webStorage = useWebStorage()
return { dispatch, state, webStorage };
};
todoStorage?.set([]); // 컴포넌트 바깥에서도 수정이 가능합니다.
function Home() {
// 조회
const todoList = useGlobalContext((ctx) => ctx.webStorage.todoList);
// 수정
const addTodo = (todo: TodoType) => {
const prev = todoList || [];
todoStorage?.set([...prev, todo]);
};
return (
<Box>
<Button onClick={() => addTodo({ text: 'new' })}>Add</Button>
<List>
{todoList?.map((todo) => (
<ListItem>{todo.text}</ListItem>
))}
</List>
</Box>
);
}
+ useSyncWebStorage 는 어떻게 구현 되었나요?
useSyncWebStorage 를 사용하기 위해선 3개의 모듈이 필요합니다.
useSyncExternalStore
: react 18 에 나온 hook 이며 구독, 알림 패턴을 통해 외부 상태와 리액트 생명주기를 연결시켜주는 훅입니다. 여기서 해당 hook 은 구독을 하는 입장에 해당합니다.
Storage
: 위의 훅에 연결될 Storage 주체입니다 useSyncExternalStore 에게 알림을 수행하는 입장에 해당합니다.
Connector
: 알림을 관리하고, useSyncExternalStore 와 Storage 를 연결 시켜주는 역할입니다.
useSyncExternalStore
useSyncExternalStore 은 크게 2가지 함수를 사용자에게 전달받아 구독 기능을 수행합니다. 다음은 useSyncExternalStore 입장에서 구독기능을 수행하는 방법에 대해 설명합니다.
subscribe
인자로 받는 subscribe 함수를 통하여 listener 함수를 인자로 사용자에게 전달해 줍니다. listerner 함수는 구독하고 있는 모듈에게 알림을 주는 '알림 함수' 에 해당하며, 함수가 실행 될시, 리랜더링을 수행합니다.
subscribe 함수의 return 값으로 '정리 함수' 를 전달받습니다. listener 를 통해 리랜더링이 되고 나면, 정리 함수를 실행시키고, 사용자에게 새로운 알림 함수를 전달해 줍니다.
getSnapShot
listener 함수가 실행되서 리랜더링이 발생한 시점의 랜더링 할 데이터를 사용자에게 전달 받습니다.
+getServerSnapShot
server-side 시점의 데이터를 사용자에게 전달 받습니다
exmple
// example
type Listener = () => void
let myListenr: Listener | null = null
let count = 0
const increase = () => {
count += 1;
if (myListener) myListener()
}
const subscribe = (listener: Listener) => {
myListener = listener;
return () => { myListener = null } // clean-up 함수
}
const getSnapShot = () => count
const Component = () => {
const count = useSyncExternalStore(subscribe, getSnapShot);
return (
<div>
<button onClick={increase}>increase</button>
<p>{count}</p>
</div>
)
}
참고
우리 프로젝트에서는?
useSyncExternalStore 로부터 전달받은 알림함수를 관리하는 connector 모듈과 함께 사용을 합니다.
// src/hooks/useSyncWebStorage.ts
import { useSyncExternalStore } from 'react';
import { ReactSyncConnector } from '@/utils/react/react-sync-connector';
export const useSyncWebStorage = <T>(connector: ReactSyncConnector<T>) => {
return useSyncExternalStore(
connector.subscribe,
connector.getSnapshot,
connector.getServerSnapShot,
);
};
ReactSyncConnector
알림함수를 관리하고, Storage 와 useSyncExternalStore 을 연결 하는 모듈입니다.
useSyncExternalStore 로 부터 리랜더링을 촉발시키는 알림함수 를 받아 관리하고,
Storage 모듈에 알림 함수를 넘겨주어 useSyncExternalStore 과 연결시켜주는 역할을 합니다.
import { ReactSynced } from './react-synced';
export class ReactSyncConnector<Data> {
listeners: Array<() => void> = [];
constructor(private synced: ReactSynced<Data> | null) {
// storage 모듈에 해당합니다.
this.synced = synced;
// storage 모듈에 알림함수를 실행시키는 emitChange 를 넘겨줍니다.
this.synced?.connect(this.emitChange);
}
// useSyncExternalStore 에서 알림함수를 받고, 저장해둡니다. useSyncExternalStore 에게 정리함수를 넘겨줍니다.
subscribe = (listener: () => void) => {
if (this.synced) this.listeners = [...this.listeners, listener];
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
};
// 알림함수가 실행되서, 리랜더링 될 시 조회할 데이터를 넘겨줍니다.
getSnapshot = () => {
return this.synced?.data ?? null;
};
getServerSnapShot = () => {
return null;
};
// 알림함수를 실행시킵니다. (구독 모듈에 알림이 전달되며, 리랜더링 됩니다.).
emitChange = () => {
for (const listener of this.listeners) {
listener();
}
};
}
React Synced
Storage 가 상속 받는 부모 Class 입니다. Connector 와 연결되어 알림함수를 받고, data 를 수정 할때 마다. 알림함수(리랜더링) 을 실행시키는 역할을 담당합니다.
export class ReactSynced<T> {
private _data: T | null = null;
public listener: (() => void) | null = null;
get data(): T | null {
return this._data;
}
// data 가 set 될때 마다. 알림함수를 실행시켜 리랜더링을 촉발합니다.
set data(data: T | null) {
this._data = data;
this.listener?.();
}
// connector 에서 리랜더링을 촉발시키는 알림함수 (emitChange) 를 받습니다.
connect = (listener: () => void) => {
this.listener = listener;
};
unConnect = () => {
this.listener = null;
};
}
SyncedStorage
storage 모듈의 주체입니다.
web storage 의 메소드를 사용하여 데이터를 관리하는 역할을 합니다.
key 와 data type, storage를 받음으로써 특정 storage 의, key 별 데이터를 타입정의와 함께 관리할 수 있습니다.
react synced 를 상속하기때문에, this.data = ...
과 같은 setter 실행시, 알림함수(리랜더링)가 실행 됩니다.
import { ReactSynced } from '@/utils/react/react-synced';
export class SyncedStorage<Data> extends ReactSynced<Data> {
constructor(public key: string, private storage: Storage) {
super();
this.key = key;
this.storage = storage;
// 클래스 생성시 data(getSnapshot이 바라보고 있는 값에)에 현재 storage 의 data 를 할당해줍니다.
this.data = this.get();
}
get = (): Data | null => {
if (this.data !== null) return this.data;
const item = this.storage.getItem(this.key);
if (item === null) return null;
return JSON.parse(item);
};
set = (data: Data | null): void => {
this.data = data;
this.storage.setItem(this.key, JSON.stringify(data));
};
remove = (): void => {
this.data = null;
this.storage.removeItem(this.key);
};
}
앞에서 소개했던 ReactSyncConnector
와 SyncedStorage
, 을 useSyncExternalStore
사용하기 쉽게 한곳에서 connector 과 storage 를 동시에 만들어 주는 역할을 담당합니다.
import { ReactSyncConnector } from '@/utils/react/react-sync-connector';
import { SyncedStorage } from './synced-storage';
export class SyncedStorageFactory {
static createLocal = <Data>(key: string) => {
const store = typeof window === 'undefined' ? null : localStorage;
return this.create<Data>(key, store); //
};
static createSession = <Data>(key: string) => {
const store = typeof window === 'undefined' ? null : sessionStorage;
return this.create<Data>(key, store);
};
static create = <Data>(key: string, store: Storage | null) => {
const storage = store ? new SyncedStorage<Data>(key, store) : null;
const connector = new ReactSyncConnector(storage);
return { storage, connector };
};
}
useGlobalHandler, useGlobalState
전역 context 에는 위와 같은 훅이 존재하는데요. 이는 비즈니스로직의 추상화를 권장하기 위해 만들어진 hook 입니다. 사용되는 범위로 훅을 분리하는것도 좋지만, 주제별 state 와 logic 을 가지고 있는 hooks 를 만드는것도 좋은 방법입니다.
example
범위로 구분하기
useGlobalContext
useGlobalState: 앱 전역에서 사용되는 state 와 state setter 로직
useGlobalHandler: 앱 전역에서 사용되는 handler 로직
주제로 구분하기