@ehfuse/forma
Version:
Advanced React state management library with individual field subscriptions - supports both forms and general state management with useFormaState
252 lines • 10.3 kB
JavaScript
/**
* useFormaState.ts
*
* Advanced state management hook with individual field subscriptions
* Optimized for arrays, objects, and complex nested data structures
*
* @author KIM YOUNG JIN (ehfuse@gmail.com)
* @license MIT License
*/
import { useRef, useCallback, useMemo, useEffect, useSyncExternalStore, } from "react";
import { FieldStore } from "../core/FieldStore";
import { devWarn, mergeActions, } from "../utils";
/**
* Individual field subscription hook for useFormaState
* useFormaState를 위한 개별 필드 구독 훅
*
* useSyncExternalStore를 사용하여 React 18의 동시성 모드를 지원하고
* 구독 등록과 값 읽기를 동기적으로 처리하여 타이밍 이슈를 방지합니다.
*
* @param store FieldStore 인스턴스
* @param fieldName 구독할 필드 이름 (dot notation 지원)
* @returns 필드의 현재 값
*/
function useFieldValue(store, fieldName) {
// useSyncExternalStore를 사용하여 동기적으로 구독 등록
// 이렇게 하면 컴포넌트 렌더링 중에 구독이 등록되어
// setValues() 호출 시점과 상관없이 항상 알림을 받을 수 있습니다.
const value = useSyncExternalStore(useCallback((onStoreChange) => {
// 구독 등록 (동기적으로 실행됨)
return store.subscribe(fieldName, onStoreChange);
}, [store, fieldName]), useCallback(() => {
// 현재 값 읽기 (동기적으로 실행됨)
return store.getValue(fieldName);
}, [store, fieldName]), useCallback(() => {
// 서버 사이드 렌더링용 초기값
return store.getValue(fieldName);
}, [store, fieldName]));
return value;
}
/**
* Hook for subscribing to a specific field in a FieldStore
* FieldStore의 특정 필드를 구독하기 위한 Hook
*
* @param store FieldStore 인스턴스
* @param path 필드 경로 (dot notation)
* @returns 필드의 현재 값
*/
export function useFieldSubscription(store, path) {
return useFieldValue(store, path);
}
export function useFormaState(initialValues = {}, options = {}) {
const { onChange, deepEquals = false, _externalStore, actions: actionsDefinition, } = options;
// 초기값 안정화: 첫 번째 렌더링에서만 초기값을 고정
// Stabilize initial values: fix initial values only on first render
const stableInitialValues = useRef(null);
if (!stableInitialValues.current) {
stableInitialValues.current = initialValues;
}
// Create or use external field store instance (persists across renders)
// 필드 스토어 인스턴스 생성 또는 외부 스토어 사용 (렌더링 간 유지)
const storeRef = useRef(null);
if (_externalStore) {
// 외부 스토어는 한번만 설정 (이미 설정되어 있으면 변경하지 않음)
// Set external store only once (don't change if already set)
if (!storeRef.current) {
storeRef.current = _externalStore;
// 외부 스토어 사용 시 초기값이 비어있으면 설정
const currentValues = _externalStore.getValues();
if (Object.keys(currentValues).length === 0 &&
Object.keys(initialValues).length > 0) {
// ⭐ initialValues 먼저 설정 (reset()이 참조함)
_externalStore.setInitialValues(initialValues);
// 그 다음 값 설정
Object.keys(initialValues).forEach((key) => {
_externalStore.setValue(key, initialValues[key]);
});
}
}
}
else if (!storeRef.current) {
storeRef.current = new FieldStore(stableInitialValues.current);
// Set up global change listener if provided
// 글로벌 변경 리스너 설정 (제공된 경우)
if (onChange) {
storeRef.current.subscribeGlobal(() => {
onChange(storeRef.current.getValues());
});
}
}
const store = storeRef.current;
// Subscribe to a specific field value with dot notation
// dot notation으로 특정 필드 값 구독
// NOTE: This is NOT a useCallback - hooks cannot be wrapped in useCallback
const useValue = (path) => {
return useFieldValue(store, path);
};
// Set a specific field value with dot notation
// dot notation으로 특정 필드 값 설정
const setValue = useCallback((path, value) => {
store.setValue(path, value);
}, [store] // store 의존성 추가
);
// Get all current values (non-reactive)
// 모든 현재 값 가져오기 (반응형 아님)
const getValues = useCallback(() => {
return store.getValues();
}, [store]); // store 의존성 추가
// Set all values at once
// 모든 값을 한 번에 설정
const setValues = useCallback((values) => {
const currentValues = store.getValues();
const newValues = { ...currentValues, ...values };
store.setValues(newValues);
}, [store] // store 의존성 추가
);
// Reset to initial values
// 초기값으로 재설정
const reset = useCallback(() => {
store.reset();
}, [store]); // store 의존성 추가
// Set new initial values (for dynamic initialization)
// 새 초기값 설정 (동적 초기화용)
const setInitialValues = useCallback((newInitialValues) => {
stableInitialValues.current = newInitialValues;
store.setInitialValues(newInitialValues);
}, [store]); // store 의존성 추가
// Handle standard input change events
// 표준 입력 변경 이벤트 처리
const handleChange = useCallback((event) => {
const target = event.target;
if (!target || !target.name) {
devWarn('useFormaState.handleChange: input element must have a "name" attribute');
return;
}
const { name, type, value, checked } = target;
let processedValue = value;
// DatePicker 처리 (Dayjs 객체) / DatePicker handling (Dayjs object)
if (value && typeof value === "object" && value.format) {
processedValue = value.format("YYYY-MM-DD");
}
// 체크박스 처리 / Checkbox handling
else if (type === "checkbox") {
processedValue = checked;
}
// 숫자 타입 처리 / Number type handling
else if (type === "number") {
processedValue = Number(value);
}
// null 값 처리 / Null value handling
else if (value === null) {
processedValue = undefined;
}
setValue(name, processedValue);
}, [setValue]);
// Bind actions to context if provided
// actions가 제공된 경우 context에 바인딩
const boundActions = useMemo(() => {
if (!actionsDefinition)
return {};
// 배열이면 병합, 객체면 그대로 사용
const mergedActions = mergeActions(actionsDefinition);
if (!mergedActions)
return {};
const context = {
values: store.getValues(),
getValue: (field) => store.getValue(field),
setValue: (field, value) => store.setValue(field, value),
setValues: (values) => {
const currentValues = store.getValues();
const newValues = { ...currentValues, ...values };
store.setValues(newValues);
},
reset: () => store.reset(),
actions: {}, // Will be filled after binding
};
const bound = {};
for (const [key, action] of Object.entries(mergedActions)) {
bound[key] = (...args) => {
// Update context.values with latest state
context.values = store.getValues();
return action(context, ...args);
};
}
// Fill context.actions with bound actions
context.actions = bound;
return bound;
}, [actionsDefinition, store]);
// Register watch callbacks
// watch 콜백 등록
useEffect(() => {
if (!options.watch)
return;
const unsubscribers = [];
for (const [path, callback] of Object.entries(options.watch)) {
const unsubscribe = store.watch(path, (value, prevValue) => {
const context = {
values: store.getValues(),
getValue: (field) => store.getValue(field),
setValue: (field, value) => store.setValue(field, value),
setValues: (values) => {
const currentValues = store.getValues();
const newValues = { ...currentValues, ...values };
store.setValues(newValues);
},
reset: () => store.reset(),
actions: boundActions,
};
callback(context, value, prevValue);
});
unsubscribers.push(unsubscribe);
}
return () => {
unsubscribers.forEach((unsub) => unsub());
};
}, [options.watch, store, boundActions]);
return {
useValue,
setValue,
getValues,
setValues,
setBatch: useCallback((updates) => {
store.setBatch(updates);
}, [store] // store 의존성 추가
),
reset,
setInitialValues,
handleChange,
hasField: useCallback((path) => {
return store.hasField(path);
}, [store] // store 의존성 추가
),
removeField: useCallback((path) => {
store.removeField(path);
}, [store] // store 의존성 추가
),
getValue: useCallback((path) => {
return store.getValue(path);
}, [store] // store 의존성 추가
),
subscribe: useCallback((callback) => {
return store.subscribeToAll(callback);
}, [store] // store 의존성 추가
),
refreshFields: useCallback((prefix) => {
store.refreshFields(prefix);
}, [store] // store 의존성 추가
),
actions: boundActions,
_store: store,
};
}
//# sourceMappingURL=useFormaState.js.map