# 상태 관리 - 폼 자동 바인딩 및 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}}" } } ] } ``` #### 체크박스 — boolean 값 보장 자동 바인딩은 **현재 값이 boolean 일 때만** `checked` 로 바인딩한다 (`DynamicRenderer` 의 `isCheckedBinding`: `typeof currentValue === 'boolean'`). 값이 `null` 이나 `undefined` 면 `value` 바인딩으로 떨어져 **빈 문자열(`''`)이 전송**된다. 서버가 그 빈 문자열을 `null` 로 저장하면(Laravel `ConvertEmptyStringsToNull` + `nullable` 규칙) 이후 `array_merge(defaults, saved)` 에서 `null` 이 기본값 `false` 를 덮는다. 그러면 화면에는 계속 해제 상태로 보이고, 다시 켜도 또 빈 문자열이 나가 **영구 고착**된다. `initLocal` 로는 막을 수 없다. 기본 병합은 shallow 라 데이터소스 응답이 `form` 객체를 통째로 교체하고, `_merge: "deep"` 을 써도 `deepMergeState` 가 `source` 의 `null` 을 그대로 반영한다. 따라서 **저장값이 boolean 이 아닐 수 있는 체크박스는 레이아웃이 boolean 을 직접 보장**한다: ```json { "type": "basic", "name": "Checkbox", "props": { "name": "method_card", "autoBinding": false, "checked": "{{!!_local.form?.method_card}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "form.method_card": "{{$event.target.checked}}", "hasChanges": true } } ] } ``` - `autoBinding: false` — 자동 바인딩의 `value` 분기 개입을 차단한다 - `checked: "{{!!...}}"` — 저장값이 `null` 이어도 `false` 로 렌더한다 - `change` 액션 — `$event.target.checked` 는 항상 boolean 이므로 상태에 boolean 만 기록된다 저장값이 항상 boolean 임이 보장되는 경우(예: 서버가 `null` 을 반환하지 않고 마이그레이션으로 기본값이 채워진 컬럼)에는 `name` 만으로 자동 바인딩해도 된다. #### 체크박스 — 라벨 표시 `Checkbox` 컴포넌트는 `label` prop 을 받지만 **렌더하지 않는다**. 표시는 부모 `Label` 이 담당한다. `label` prop 에만 의존하면 라벨이 화면에 나타나지 않으므로, `Label` 로 감싸고 텍스트를 형제 노드로 둔다. ```json { "type": "basic", "name": "Label", "props": { "className": "flex items-center gap-2 cursor-pointer" }, "children": [ { "type": "basic", "name": "Checkbox", "props": { "name": "agree_terms" } }, { "type": "basic", "name": "Span", "text": "$t:auth.register.agree_terms" } ] } ``` `Label` 로 감싸면 라벨 텍스트를 클릭해도 토글되고 접근성 이름이 부여된다. #### 전역 상태 바인딩 (`_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 핸들러 상세