Files
Gnuboard7/docs/frontend/state-management-forms.md
T
2026-04-01 10:30:52 +09:00

1239 lines
33 KiB
Markdown

# 상태 관리 - 폼 자동 바인딩 및 setState
> **메인 문서**: [state-management.md](state-management.md)
> **관련 문서**: [state-management-advanced.md](state-management-advanced.md) | [data-binding.md](data-binding.md)
---
## 목차
1. [레이아웃 레벨 상태 초기화](#레이아웃-레벨-상태-초기화)
2. [폼 자동 바인딩 (FormContext)](#폼-자동-바인딩-formcontext)
3. [폼 변경 감지 (trackChanges/hasChanges)](#폼-변경-감지-trackchangeshaschanges)
4. [setState 액션](#setstate-액션)
5. [깊은 병합 (Deep Merge)](#깊은-병합-deep-merge)
- [얕은 병합 (merge: "shallow")](#얕은-병합-merge-shallow)
6. [payload 표현식 평가](#payload-표현식-평가)
---
## 레이아웃 레벨 상태 초기화
> **버전**: engine-v1.11.0+
레이아웃 JSON 최상위에서 `initLocal`, `initGlobal`, `initIsolated`로 정적 초기값을 설정할 수 있습니다.
### initLocal (로컬 상태 초기값)
레이아웃 JSON 최상위에 `initLocal` 블록을 정의하여 `_local` 초기값을 설정합니다:
```json
{
"version": "1.0.0",
"layout_name": "admin_board_form",
"initLocal": {
"activeTab": "basic",
"hasChanges": false,
"isSaving": false,
"formData": {
"name": "",
"slug": "",
"type": "basic",
"categories": [],
"show_view_count": false,
"use_comment": false
}
},
"body": { ... }
}
```
| 특징 | 설명 |
|------|------|
| ✅ 즉시 사용 가능 | 레이아웃 로드 시 바로 초기값 적용 |
| ✅ 타입 힌트 | boolean 필드에 `false` 지정 시 Toggle 자동 바인딩 작동 |
| 정적 값만 | 동적 표현식(`{{}}`) 미지원 |
> **하위 호환**: 기존 `state` 속성도 `initLocal`과 동일하게 동작합니다 (deprecated).
### initGlobal (전역 상태 초기값)
레이아웃 JSON 최상위에 `initGlobal` 블록을 정의하여 `_global` 초기값을 설정합니다:
```json
{
"initGlobal": {
"sidebarOpen": true,
"theme": "light"
}
}
```
### initIsolated (격리 상태 초기값)
레이아웃 JSON 최상위에 `initIsolated` 블록을 정의하여 `_isolated` 초기값을 설정합니다:
```json
{
"initIsolated": {
"selectedItems": [],
"filterOptions": {
"status": "all"
}
}
}
```
### 실행 순서 및 병합 동작
```text
1. 레이아웃 initLocal/initGlobal/initIsolated (정적 기본값)
2. 데이터소스 initLocal/initGlobal/initIsolated (API 응답으로 깊은 병합)
3. initActions (동적 초기화)
```
**병합 방식**: 데이터소스의 `initLocal`/`initGlobal`/`initIsolated`가 적용될 때 **깊은 병합**됩니다:
- 레이아웃 레벨 초기값은 기본값으로 유지
- 데이터소스 응답에서 추가되는 값만 병합/덮어쓰기
```json
// 레이아웃 initLocal (기본값)
{
"initLocal": {
"formData": {
"name": "",
"type": "basic",
"category": "default" // 기본값
}
}
}
// 데이터소스 응답 (API)
{
"name": "Product A",
"type": "premium"
// category 없음
}
// 결과 (_local.formData)
{
"name": "Product A", // API 값
"type": "premium", // API 값
"category": "default" // 기본값 유지
}
```
---
## 데이터소스 initLocal (API 응답 바인딩)
`data_sources`의 `initLocal` 옵션으로 API 응답을 로컬 상태에 자동 바인딩합니다:
```json
{
"state": {
"formData": {
"name": "",
"slug": ""
}
},
"data_sources": [
{
"id": "board",
"type": "api",
"endpoint": "/api/boards/{{route?.id}}",
"method": "GET",
"auto_fetch": "{{!!route?.id}}",
"condition": "{{!!route?.id}}",
"initLocal": "formData"
}
]
}
```
| 옵션 | 설명 |
|------|------|
| `initLocal` | API 응답 데이터를 저장할 `_local` 경로 (예: `"formData"` → `_local.formData`) |
| `auto_fetch` | 자동 fetch 조건 (편집 모드에서만 fetch) |
| `condition` | API 호출 조건 |
**동작 순서:**
```text
1. 레이아웃 로드 → state.formData 초기값 적용
2. route?.id 존재 → API 자동 호출
3. API 응답 수신 → _local.formData에 응답 데이터 덮어쓰기
4. 컴포넌트 리렌더링 → 폼 필드에 API 데이터 표시
```
### 두 방법 함께 사용 (권장)
```json
{
"state": {
"formData": {
"name": "",
"type": "basic",
"use_comment": false
}
},
"data_sources": [
{
"id": "board",
"endpoint": "/api/boards/{{route?.id}}",
"auto_fetch": "{{!!route?.id}}",
"initLocal": "formData"
}
]
}
```
- **생성 모드** (`route?.id` 없음): `state.formData` 초기값 사용
- **편집 모드** (`route?.id` 존재): API 응답이 `formData`를 덮어씀
---
## 폼 자동 바인딩 (FormContext)
> **버전**: engine-v1.2.0+
폼 필드의 `value`와 `onChange`를 자동으로 바인딩하는 시스템입니다. `dataKey` prop을 사용하여 verbose한 actions 블록 없이 간결하게 폼을 구성할 수 있습니다.
### 핵심 원칙
```text
✅ dataKey로 폼 데이터 경로 지정: 자식 컴포넌트들이 해당 경로에 자동 바인딩
✅ name prop으로 필드 식별: Input, Textarea, Select의 name이 필드명으로 사용
✅ trackChanges로 변경 추적: hasChanges가 자동으로 true로 설정됨
✅ 명시적 value 우선: value가 지정되어 있으면 자동 바인딩 비활성화
```
### 기존 방식 (verbose)
```json
{
"name": "Input",
"props": {
"name": "title",
"value": "{{_local.formData.title}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"formData": {
"...": "{{_local.formData}}",
"title": "{{event.target.value}}"
},
"hasChanges": true
}
}
]
}
```
### 새로운 방식 (자동 바인딩)
#### 로컬 상태 바인딩 (기본)
```json
{
"name": "Div",
"props": {
"dataKey": "formData",
"trackChanges": true
},
"children": [
{
"name": "Input",
"props": { "name": "title" }
},
{
"name": "Textarea",
"props": { "name": "content" }
},
{
"name": "Select",
"props": {
"name": "category",
"options": "{{categories}}"
}
}
]
}
```
#### 전역 상태 바인딩 (`_global.` 접두사)
> **버전**: engine-v1.3.0+
전역 상태에 폼 데이터를 바인딩해야 하는 경우 `_global.` 접두사를 사용합니다.
##### 기본 사용법
```json
{
"name": "Div",
"props": {
"dataKey": "_global.formData",
"trackChanges": true
},
"children": [
{
"name": "Input",
"props": { "name": "title" }
},
{
"name": "Toggle",
"props": { "name": "isActive" }
}
]
}
```
##### 필수: init_actions로 초기값 설정
`dataKey="_global.xxx"` 패턴을 사용하려면 **반드시** `init_actions`에서 전역 상태를 초기화해야 합니다:
```json
{
"version": "1.0.0",
"layout_name": "login_page",
"init_actions": [
{
"handler": "setState",
"params": {
"target": "global",
"loginForm": {
"email": "",
"password": ""
}
}
}
],
"components": [
{
"type": "basic",
"name": "Form",
"dataKey": "_global.loginForm",
"children": [
{
"type": "basic",
"name": "Input",
"props": { "name": "email", "type": "email" }
},
{
"type": "basic",
"name": "Input",
"props": { "name": "password", "type": "password" }
}
],
"actions": [
{
"type": "submit",
"handler": "login",
"params": {
"body": {
"email": "{{_global.loginForm.email}}",
"password": "{{_global.loginForm.password}}"
}
}
}
]
}
]
}
```
##### 왜 init_actions가 필요한가?
| 항목 | state 블록 | init_actions |
|------|-----------|--------------|
| 적용 대상 | `_local` 상태만 | `_local` 또는 `_global` 상태 |
| 동적 값 | 불가 | 가능 (`{{query.xxx}}` 등) |
| `dataKey="_global.xxx"` | ❌ 작동 안함 | ✅ 작동함 |
```text
주의: state 블록은 _local에만 적용됨
- "state": { "formData": {...} } → _local.formData (O)
- "state": { "_global.formData": {...} } → 작동 안함 (X)
✅ 전역 상태 초기화는 반드시 init_actions 사용
- init_actions + setState target: "global" → _global.formData (O)
```
##### init_actions에서 _local 상태 초기화 (URL 쿼리 동기화)
> **버전**: engine-v1.4.0+
init_actions에서 `target: "global"`과 `_local` 래퍼를 함께 사용하여 _local 상태를 동적 값으로 초기화할 수 있습니다. 이 패턴은 특히 **URL 쿼리 파라미터를 _local 상태에 동기화**할 때 사용됩니다.
```json
{
"version": "1.0.0",
"layout_name": "admin_product_list",
"init_actions": [
{
"handler": "setState",
"params": {
"target": "global",
"_local": {
"filter": {
"searchField": "{{query.search_field || 'all'}}",
"displayStatus": "{{query.display_status || 'all'}}",
"taxStatus": "{{query.tax_status || 'all'}}",
"category": "{{query.category || ''}}",
"keyword": "{{query.keyword || ''}}"
}
}
}
}
],
"components": [...]
}
```
**동작 원리:**
| 항목 | 설명 |
|------|------|
| `target: "global"` | 상태 저장소 타겟 지정 (전역 스토어 접근) |
| `_local: {...}` | _local 상태 네임스페이스에 데이터 저장 |
| `{{query.xxx}}` | URL 쿼리 파라미터에서 동적 값 바인딩 |
**왜 이 패턴이 필요한가?**
| 방법 | 동적 값 지원 | _local 초기화 | 사용 시점 |
|------|-------------|---------------|----------|
| `state` 블록 | ❌ | ✅ | 정적 초기값만 필요할 때 |
| `init_actions` + `target: "global"` + 직접 키 | ✅ | ❌ (_global에 저장) | 전역 상태 초기화 |
| `init_actions` + `target: "global"` + `_local` 래퍼 | ✅ | ✅ | URL 쿼리 → _local 동기화 |
**사용 사례:**
- 목록 화면에서 필터 값을 URL과 동기화 (뒤로가기/새로고침 시 유지)
- 페이지네이션 상태를 URL에서 복원
- 검색 키워드를 URL 파라미터에서 초기화
##### 동작 방식
- `dataKey`가 `_global.`로 시작하면 `_global.formData`에 바인딩
- 입력값 변경 시 `_global.formData.title`, `_global.formData.isActive` 등이 자동 업데이트
- `trackChanges`가 `true`이면 `_global.hasChanges`가 자동으로 `true`로 설정
##### 사용 사례
- 여러 레이아웃/컴포넌트에서 같은 폼 데이터를 공유해야 할 때
- 모달에서 입력받은 데이터를 부모 페이지에서 사용해야 할 때
- 사이드 패널과 메인 영역 간 데이터 동기화가 필요할 때
- 로그인 폼처럼 실패 시에도 입력값을 유지해야 할 때
#### 격리된 상태 바인딩 (`_isolated.` 접두사)
> **버전**: engine-v1.14.0+
격리된 상태에 폼 데이터를 바인딩해야 하는 경우 `_isolated.` 접두사를 사용합니다. 격리된 상태는 해당 영역 내에서만 유효하며, 상태 변경 시 전체 레이아웃이 아닌 격리된 영역만 리렌더링됩니다.
##### 기본 사용법
```json
{
"type": "Div",
"isolatedState": {
"miniForm": {
"name": "",
"email": ""
}
},
"isolatedScopeId": "quick-form",
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"dataKey": "_isolated.miniForm",
"trackChanges": true
},
"children": [
{
"type": "basic",
"name": "Input",
"props": { "name": "name", "placeholder": "이름" }
},
{
"type": "basic",
"name": "Input",
"props": { "name": "email", "type": "email", "placeholder": "이메일" }
}
]
}
]
}
```
##### 필수: isolatedState로 초기값 정의
```text
주의: dataKey="_isolated.xxx" 패턴을 사용하려면
상위 컴포넌트에 isolatedState 속성이 정의되어 있어야 합니다.
✅ isolatedState가 있는 컴포넌트 내에서만 _isolated 바인딩 가능
❌ isolatedState 없이 _isolated 바인딩 시 → _local로 폴백 + 경고 로그
```
##### 동작 방식
- `dataKey`가 `_isolated.`로 시작하면 `_isolated.miniForm`에 바인딩
- 입력값 변경 시 `_isolated.miniForm.name`, `_isolated.miniForm.email` 자동 업데이트
- 상태 변경 시 격리된 영역만 리렌더링 (성능 최적화)
- `trackChanges`가 `true`이면 `_isolated.hasChanges`가 자동으로 `true`로 설정
##### 사용 사례
| 사례 | 설명 |
|------|------|
| 빈번한 입력 폼 | 검색 필터, 실시간 미리보기 폼 등 잦은 입력이 발생하는 경우 |
| 독립적인 UI 영역 | 사이드 패널, 미니 폼 등 다른 영역과 상태를 공유하지 않는 경우 |
| 성능 최적화 필요 | 입력 시 전체 레이아웃 리렌더링을 방지해야 하는 경우 |
##### _local vs _global vs _isolated 비교
| 접두사 | 스코프 | 리렌더링 범위 | 라이프사이클 | 사용 시점 |
| ------ | ------ | ------------- | ------------ | -------- |
| (없음) / `_local.` | 현재 레이아웃 | 전체 레이아웃 | 페이지 이동 시 초기화 | 일반 폼 데이터 |
| `_global.` | 앱 전체 | 전체 앱 | 유지됨 | 다중 페이지 공유 데이터 |
| `_isolated.` | 격리된 컴포넌트 | 격리된 영역만 | 컴포넌트 언마운트 시 소멸 | 빈번한 인터랙션 영역 |
### Props 설명
| Prop | 타입 | 필수 | 설명 |
|------|------|------|------|
| `dataKey` | string | ✅ | 폼 데이터 경로. 기본적으로 `_local` 바인딩 (예: `"formData"` → `_local.formData`). `_global.` 접두사로 전역 상태 바인딩 가능 |
| `trackChanges` | boolean | ❌ | `true`이면 입력 시 `hasChanges`를 자동으로 `true`로 설정 |
| `debounce` | number | ❌ | 밀리초 단위 debounce 지연 시간. 빈번한 입력 시 성능 최적화에 사용 (예: `300`) |
| `autoBinding` | boolean | ❌ | `false`로 설정 시 해당 필드의 자동 바인딩을 명시적으로 비활성화 (engine-v1.17.6+). 커스텀 핸들러나 G7Core API로 상태를 관리하는 경우 사용 |
### 자동 바인딩 조건
자동 바인딩이 적용되려면 다음 조건을 **모두** 충족해야 합니다:
1. 부모(또는 조상) 컴포넌트에 `dataKey` prop이 있어야 함
2. 자식 컴포넌트에 `name` prop이 있어야 함
3. 자식 컴포넌트에 `value` prop이 **명시적으로 지정되어 있지 않아야** 함
### 자동 바인딩 스킵 조건 (engine-v1.17.6+)
컴포넌트 props에 `autoBinding: false`가 명시되면 해당 필드의 자동 바인딩이 스킵됩니다:
```json
{
"type": "basic",
"name": "Input",
"props": {
"type": "radio",
"name": "purchase_restriction",
"value": "none",
"checked": "{{(_local.form.purchase_restriction ?? 'none') === 'none'}}",
"autoBinding": false
}
}
```
- 스킵된 필드만 자동 바인딩이 해제되며, 같은 폼의 다른 필드는 종전대로 자동 바인딩 유지
- 라디오 버튼, 체크박스 등 자동 바인딩의 `value` 덮어쓰기가 유해한 컴포넌트에서 사용
- `autoBinding: false` 사용 시 `value`/`checked`/`onChange`를 직접 바인딩해야 함
### 지원 컴포넌트
| 컴포넌트 | 바인딩 방식 | 비고 |
|----------|------------|------|
| Input | `value` + `onChange` | text, number, email 등 모든 타입 |
| Textarea | `value` + `onChange` | |
| Select | `value` + `onChange` | |
| Toggle | `value` + `onChange` | boolean 값 자동 감지하여 `checked` 처리 |
| Checkbox | `value` + `onChange` | boolean 값 자동 감지하여 `checked` 처리 |
### 동작 방식
```text
1. DynamicRenderer가 dataKey prop 감지
2. FormProvider로 자식 컴포넌트들을 감싸기
3. 자식 컴포넌트 렌더링 시 name prop 감지
4. state[dataKey][name] 값의 타입에 따라 자동 바인딩:
- boolean: value + onChange(e.target.checked) - Toggle, Checkbox용
- 그 외: value + onChange(e.target.value) - Input, Textarea, Select용
5. trackChanges가 true이면 hasChanges도 자동 설정
```
### 중첩 구조 지원
`dataKey`가 있는 컴포넌트 내부에 여러 단계의 자식이 있어도 자동 바인딩이 작동합니다:
```json
{
"name": "Form",
"props": { "dataKey": "formData" },
"children": [
{
"name": "Div",
"props": { "className": "form-group" },
"children": [
{
"name": "Label",
"text": "제목"
},
{
"name": "Input",
"props": { "name": "title" }
}
]
}
]
}
```
### 명시적 바인딩 혼용
일부 필드만 명시적으로 바인딩하고 나머지는 자동 바인딩 사용:
```json
{
"name": "Div",
"props": { "dataKey": "formData" },
"children": [
{
"name": "Input",
"props": { "name": "title" }
},
{
"name": "Input",
"props": {
"name": "customField",
"value": "{{_local.customValue}}",
"onChange": "customHandler"
}
}
]
}
```
위 예시에서 `title`은 자동 바인딩되고, `customField`는 명시적 `value`가 있으므로 자동 바인딩되지 않습니다.
### setState dot notation 지원
폼 자동 바인딩과 함께 `setState` 액션에서도 dot notation 경로를 지원합니다:
```json
{
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "_local.formData.title",
"value": "{{event.target.value}}"
}
}
]
}
```
이 방식은 기존의 spread 연산자 방식보다 간결하며, 다른 필드를 덮어쓰지 않고 부분 업데이트합니다.
### 주의사항
| 항목 | 설명 |
|------|------|
| Form 태그 필수 아님 | `dataKey`는 어떤 컴포넌트에든 적용 가능 (Div, Section 등) |
| 초기 데이터 필요 | `state.formData` 또는 `data_sources`의 `initLocal`로 초기화 필요 |
| boolean 타입 감지 | 초기값이 boolean이어야 Toggle/Checkbox 자동 바인딩 작동 |
| FileInput 미지원 | File 객체 처리를 위한 별도 로직 필요 |
| debounce 중복 주의 | 자동 바인딩 `debounce`와 액션의 `debounce` 속성을 함께 사용하면 이중 지연 발생 (예: 300ms + 300ms = 600ms). 아래 "debounce 레벨 구분" 참조 |
| autoBinding: false 시 명시적 바인딩 필수 | `autoBinding: false` 사용 시 `value`/`onChange`를 직접 바인딩해야 함. 미설정 시 필드가 빈 상태로 표시됨 (engine-v1.17.6+) |
### debounce 레벨 구분
G7에서 debounce는 두 가지 레벨에서 설정할 수 있습니다. 혼동에 주의하세요.
| 레벨 | 설정 위치 | 적용 대상 | 설명 |
|------|----------|----------|------|
| **컴포넌트 레벨** | `dataKey` 부모의 `debounce` prop | 자동 바인딩 onChange | 폼 자동 바인딩의 상태 업데이트 지연 |
| **액션 레벨** | `actions[].debounce` 속성 | 개별 액션 실행 | 특정 액션(apiCall, refetchDataSource 등)의 실행 지연 |
```json
{
"type": "basic",
"name": "Div",
"props": {
"dataKey": "formData",
"debounce": 300
},
"children": [
{
"type": "basic",
"name": "Input",
"props": { "name": "keyword" },
"actions": [
{
"type": "change",
"handler": "refetchDataSource",
"params": { "dataSourceId": "search_results" },
"debounce": 500
}
]
}
]
}
```
```text
위 예시에서 두 debounce가 모두 적용되면:
- 컴포넌트 debounce (300ms) → _local.formData.keyword 업데이트 지연
- 액션 debounce (500ms) → refetchDataSource 실행 지연
- 총 지연: 최대 800ms (이중 지연 발생!)
✅ 권장: 하나만 선택
- 폼 자동 바인딩만 사용 → 컴포넌트 debounce만 설정
- 명시적 액션 사용 → 액션 debounce만 설정
```
---
## 폼 변경 감지 (trackChanges/hasChanges)
> **버전**: engine-v1.3.0+
폼 데이터가 변경되었는지 추적하여 저장 버튼 활성화 등의 UI 상태를 자동으로 관리하는 시스템입니다.
### 변경 감지 핵심 원칙
```text
✅ trackChanges로 변경 추적 활성화: 부모 컴포넌트에 trackChanges: true 설정
✅ hasChanges 자동 관리: 입력 시 _local.hasChanges가 자동으로 true로 설정
✅ 초기 로드 시 리셋: initLocal 적용 시 hasChanges가 자동으로 false로 초기화
✅ 저장 성공 시 리셋: onSuccess 핸들러에서 hasChanges: false로 수동 리셋 필요
```
### 기본 사용법
```json
{
"id": "form_container",
"type": "basic",
"name": "Div",
"dataKey": "formData",
"trackChanges": true,
"children": [
{
"name": "Input",
"props": { "name": "title" }
},
{
"name": "Input",
"props": { "name": "description" }
}
]
}
```
### 저장 버튼과 연동
`hasChanges` 플래그를 사용하여 저장 버튼의 활성화 상태를 제어합니다:
```json
{
"id": "save_button",
"type": "basic",
"name": "Button",
"props": {
"disabled": "{{!_local.hasChanges || _local.isSaving}}"
},
"text": "$t:common.save",
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": true
}
},
{
"handler": "apiCall",
"target": "/api/settings",
"params": { "method": "POST", "body": "{{_local.formData}}" },
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"hasChanges": false
}
},
{
"handler": "toast",
"params": { "type": "success", "message": "$t:common.save_success" }
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false
}
}
]
}
]
}
]
}
```
### 취소 버튼과 연동
데이터를 다시 불러와서 폼을 초기 상태로 되돌립니다:
```json
{
"id": "cancel_button",
"type": "basic",
"name": "Button",
"text": "$t:common.cancel",
"actions": [
{
"type": "click",
"handler": "refetchDataSource",
"params": {
"dataSourceId": "settings"
}
}
]
}
```
`refetchDataSource` 실행 시 `initLocal`이 다시 적용되면서 `hasChanges`가 자동으로 `false`로 리셋됩니다.
### 동작 흐름
```text
1. 레이아웃 로드
↓
2. data_sources의 initLocal 적용
→ _local.formData = API 응답 데이터
→ _local.hasChanges = false (자동 초기화)
↓
3. 사용자가 폼 필드 수정
→ _local.formData.필드명 = 새 값
→ _local.hasChanges = true (trackChanges가 true일 때 자동)
↓
4. 저장 버튼 활성화 (hasChanges가 true이므로)
↓
5. 저장 버튼 클릭 → API 호출
↓
6-a. 저장 성공
→ onSuccess에서 hasChanges: false 설정
→ 저장 버튼 비활성화
6-b. 저장 실패
→ hasChanges는 true 유지
→ 저장 버튼 활성화 유지
```
### 변경 감지 Props 설명
| Prop | 타입 | 위치 | 설명 |
|------|------|------|------|
| `dataKey` | string | 부모 컴포넌트 | 폼 데이터 경로 (예: `"formData"` → `_local.formData`) |
| `trackChanges` | boolean | 부모 컴포넌트 | `true`이면 자식 입력 필드 변경 시 `hasChanges` 자동 설정 |
| `hasChanges` | boolean | `_local` 상태 | 폼 데이터 변경 여부 (자동 관리됨) |
### initLocal과 hasChanges 자동 리셋
`data_sources`에서 `initLocal`이 설정된 경우, API 응답이 로컬 상태에 적용될 때 `hasChanges`가 자동으로 `false`로 초기화됩니다:
```json
{
"data_sources": [
{
"id": "settings",
"type": "api",
"endpoint": "/api/admin/settings",
"method": "GET",
"auto_fetch": true,
"initLocal": "formData",
"refetchOnMount": true
}
]
}
```
이 동작은 `DynamicRenderer.tsx`에서 다음과 같이 처리됩니다:
```typescript
// initLocal 적용 시 hasChanges를 false로 초기화
setLocalDynamicState(prev => ({
loadingActions: prev.loadingActions || {},
...localInitData,
hasChanges: false,
}));
```
### 전역 상태에서의 사용
`dataKey`가 `_global.`로 시작하는 경우, `hasChanges`도 전역 상태에 저장됩니다:
```json
{
"name": "Div",
"props": {
"dataKey": "_global.formData",
"trackChanges": true
},
"children": [...]
}
```
이 경우 `_global.hasChanges`가 자동으로 관리됩니다.
### 변경 감지 주의사항
| 항목 | 설명 |
|------|------|
| 저장 성공 시 수동 리셋 필요 | `onSuccess`에서 `hasChanges: false` 명시적 설정 필요 |
| 원본 값 비교 미지원 | 현재는 입력 발생 시 무조건 dirty로 처리 (원본 값과 동일해도 hasChanges가 true) |
| dataKey 필수 | `trackChanges`는 `dataKey`와 함께 사용해야 동작 |
| refetchDataSource 활용 | 취소 시 `refetchDataSource`로 원본 데이터 다시 로드하면 자동 리셋 |
---
## setState 액션
컴포넌트 로컬 상태 또는 전역 상태를 업데이트하는 액션입니다.
### 기본 문법
```json
{
"actions": [
{
"event": "onClick",
"type": "setState",
"target": "component" | "global",
"payload": {
"key": "value"
}
}
]
}
```
### 필드 설명
| 필드 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `event` | string | ✅ | 이벤트 타입 (`onClick`, `onChange` 등) |
| `type` | string | ✅ | `"setState"` 고정 |
| `target` | string | ✅ | `"component"` (로컬) 또는 `"global"` (전역) |
| `payload` | object | ✅ | 업데이트할 상태 객체 |
### target 옵션 비교
| target | 접근 방식 | 영향 범위 | 사용 시점 |
|--------|----------|----------|----------|
| `"component"` | 해당 컴포넌트 내부 | 해당 컴포넌트만 | 컴포넌트 내부 상태 관리 |
| `"global"` | `_global.속성` | 모든 컴포넌트 | 여러 컴포넌트 간 상태 공유 |
### 컴포넌트 로컬 상태 업데이트
```json
{
"actions": [
{
"event": "onClick",
"type": "setState",
"target": "component",
"payload": {
"isOpen": true,
"selectedTab": "profile"
}
}
]
}
```
### 전역 상태 업데이트
```json
{
"actions": [
{
"event": "onClick",
"type": "setState",
"target": "global",
"payload": {
"sidebarOpen": false,
"currentTheme": "dark"
}
}
]
}
```
---
## 깊은 병합 (Deep Merge)
> **버전**: engine-v1.2.0+
로컬 상태 업데이트 시 중첩 객체의 특정 필드만 업데이트하고 나머지 필드는 자동으로 유지됩니다.
### 병합 모드 선택
| 모드 | 옵션 | 동작 | 사용 시점 |
|------|------|------|----------|
| **깊은 병합** (기본) | 생략 또는 `"merge": "deep"` | 재귀적 깊은 병합 (중첩 객체 보존) | 개별 필드 업데이트 |
| **얕은 병합** | `"merge": "shallow"` | 최상위 키만 덮어쓰기 (1단계) | 프리셋 적용, 필터 초기화 |
| **완전 교체** | `"merge": "replace"` | 기존 상태 완전 무시, 새 값으로 교체 | 폼 초기화, 서버 데이터로 전체 교체 |
#### 3모드 비교 예시
```text
현재 상태: { form: { name: "기존", price: 1000 }, ui: { tab: "basic" } }
업데이트: { form: { name: "신규" } }
replace: { form: { name: "신규" } } ← ui 키 자체가 사라짐
shallow: { form: { name: "신규" }, ui: { tab: "basic" } } ← ui 키 유지, form 전체 교체
deep: { form: { name: "신규", price: 1000 }, ui: { tab: "basic" } } ← price도 유지
```
### 완전 교체 (merge: "replace")
> **버전**: engine-v1.18.0+
기존 상태를 완전히 무시하고 새 값으로 교체합니다. 서버에서 받은 데이터로 전체 상태를 덮어쓰거나, 폼을 완전 초기화할 때 사용합니다.
```json
{
"handler": "setState",
"params": {
"target": "_local",
"merge": "replace",
"formData": {
"name": "",
"price": 0,
"description": ""
}
}
}
```
**주의사항**:
```text
replace는 해당 target의 모든 기존 키를 제거합니다
- local target: 기존 _local 전체가 payload로 교체됨
- global target: _global 전체가 payload로 교체됨 (modalStack 등도 사라질 수 있음)
- 부분 업데이트에는 deep 또는 shallow를 사용하세요
```
### 얕은 병합 (merge: "shallow")
> **버전**: engine-v1.4.0+
전체 객체를 교체해야 할 때 사용합니다. 프리셋 적용이나 폼 초기화 시 기존 값을 모두 덮어쓰고 새 값으로 교체합니다.
```json
{
"handler": "setState",
"params": {
"target": "_local",
"merge": "shallow",
"filter": {
"searchField": "all",
"displayStatus": "all",
"taxStatus": "all",
"category": ""
}
}
}
```
**얕은 병합 동작 예시**:
```text
// 현재 상태
{
filter: { searchField: "name", displayStatus: "visible", taxStatus: "taxable", keyword: "테스트" }
}
// 업데이트 payload (merge: "shallow")
{
filter: { searchField: "all", displayStatus: "all", taxStatus: "all" }
}
// 결과 (얕은 병합) - filter 전체가 교체됨
{
filter: { searchField: "all", displayStatus: "all", taxStatus: "all" }
}
// 비교: 깊은 병합이었다면 (기본 동작)
{
filter: { searchField: "all", displayStatus: "all", taxStatus: "all", keyword: "테스트" }
}
```
**사용 사례**:
| 사례 | 설명 |
|------|------|
| 프리셋 적용 | 저장된 필터 프리셋을 불러와 모든 필터 값 교체 |
| 폼 초기화 | 초기화 버튼 클릭 시 모든 폼 필드를 기본값으로 리셋 |
| 부분 객체 교체 | 특정 최상위 키의 객체를 통째로 교체하되 다른 키는 유지 |
> **참고**: 전체 상태를 서버 데이터로 완전히 교체하려면 `merge: "replace"`를 사용하세요.
**주의사항**:
```text
얕은 병합은 최상위 키만 덮어씁니다
- filter 키 전체가 교체됨
- 다른 최상위 키(visibleFilters, isEditMode 등)는 유지됨
```
### 기존 방식 (spread 연산자 필요)
```json
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"formData": {
"...": "{{_local.formData}}",
"name": "{{$event.target.value}}"
},
"hasChanges": true
}
}
```
### 새로운 방식 (깊은 병합)
```json
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"formData": {
"name": "{{$event.target.value}}"
},
"hasChanges": true
}
}
```
spread 연산자 없이도 `formData.name`만 업데이트되고 `formData.email`, `formData.slug` 등은 자동으로 유지됩니다.
### 병합 규칙
| 값 타입 | 동작 | 예시 |
|---------|------|------|
| 일반 객체 (Object) | 기존 상태와 병합 | `{ formData: { name: "new" } }` → formData.name만 변경 |
| 배열 (Array) | 덮어쓰기 (병합 안함) | `{ tags: [1, 2] }` → tags 전체 교체 |
| null | 덮어쓰기 | `{ formData: null }` → formData를 null로 설정 |
| 기본 타입 | 덮어쓰기 | `{ count: 5 }` → count를 5로 설정 |
### 동작 예시
```text
// 현재 상태
{
formData: { name: "old", email: "a@b.com", slug: "old-slug" },
hasChanges: false
}
// 업데이트 payload
{
formData: { name: "new" },
hasChanges: true
}
// 결과 (깊은 병합)
{
formData: { name: "new", email: "a@b.com", slug: "old-slug" },
hasChanges: true
}
```
### 하위 호환성
기존 spread 연산자 패턴(`"...": "{{_local.formData}}"`)은 계속 지원됩니다. spread 연산자가 있으면 `evaluateExpressions()`에서 먼저 처리되어 완성된 객체가 전달됩니다.
### 깊은 병합 제한사항
| 항목 | 설명 |
|------|------|
| 1단계 병합만 지원 | `formData.nested.field` 같은 2단계 이상 병합은 미지원 |
| 전역 상태는 미적용 | `target: "global"`인 경우 깊은 병합 미적용 |
| 배열은 병합 안함 | 배열 값은 전체가 교체됨 |
---
## payload 표현식 평가
payload 값에 `{{}}` 표현식을 사용하여 동적으로 값을 계산할 수 있습니다.
### 지원하는 표현식
```json
{
"actions": [
{
"event": "onClick",
"type": "setState",
"target": "global",
"payload": {
// boolean 토글
"sidebarOpen": "{{!_global.sidebarOpen}}",
// 조건부 값 설정 (삼항 연산자)
"theme": "{{_global.theme == 'dark' ? 'light' : 'dark'}}",
// 비교 연산
"isLarge": "{{count > 100}}",
// 정적 값 (표현식 없음)
"selectedTab": "profile"
}
}
]
}
```
### 표현식 타입별 예시
| 표현식 타입 | 예시 | 결과 |
|------------|------|------|
| boolean 토글 | `"{{!_global.isOpen}}"` | `true` ↔ `false` |
| 삼항 연산자 | `"{{_global.theme == 'dark' ? 'light' : 'dark'}}"` | `'light'` 또는 `'dark'` |
| 비교 연산 | `"{{count > 100}}"` | `true` 또는 `false` |
| 정적 값 | `"profile"` | `"profile"` |
---
## 관련 문서
- [상태 관리 개요](state-management.md) - _global, _local 기본 개념
- [고급 상태 관리](state-management-advanced.md) - 예약 상태, G7Core.state API
- [데이터 바인딩](data-binding.md) - `{{}}` 표현식 문법
- [액션 핸들러](actions.md) - setState 핸들러 상세