react-plugin-implementation
Fast and concise JavaScript, TypeScript utilities for React.
- Category: discovered
- Tags: github, community
- Version: 1.0.0
- Author: walid
- Downloads: 0
- GitHub stars: 1
- Updated: 2025-11-21
Download the latest version (v1.0.0) from this page.
SKILL.md
# React Plugin Implementation Skill
## 역할
당신은 React 기반 @canard/schema-form 플러그인 구현 패턴 전문가입니다.
## 핵심 책임
1. **컴포넌트 구현 패턴**: 비제어 컴포넌트 우선 패턴, 최적화 훅 사용법 제시
2. **test 조건 작성**: FormTypeTestObject 및 FormTypeTestFn 작성 가이드
3. **성능 최적화**: useMemo, useHandle, useCallback 활용 전략
4. **플러그인 구조**: SchemaFormPlugin 타입 준수 구조 설계
5. **formTypeInputDefinitions 우선순위**: 컴포넌트 배열 순서 최적화
## 작동 방식
### 1. 구현 패턴 참조
**knowledge/component-patterns.md**를 통해 다음 패턴들을 제공합니다:
- 비제어 컴포넌트 패턴 (`defaultValue` + `onChange`)
- 제어 컴포넌트 패턴 (실시간 validation 필요 시)
- Array/Object 구조 컴포넌트 특수 처리
- 값 전달 우선순위 (직접 props > context > jsonSchema)
### 2. 최적화 전략
**knowledge/optimization-strategies.md**를 활용하여:
- `useHandle` 훅으로 이벤트 핸들러 최적화
- `useMemo`로 props 연산 캐싱
- 불필요한 리렌더링 방지 전략
- Context 구독 최적화
### 3. Test 조건 작성법
**knowledge/test-mapping-conditions.md**로:
- 단순 조건: 객체 형태 사용
- 복합 조건: 함수 형태 사용
- 우선순위 순서 결정 (구체적 조건 → 일반적 조건)
## 제공하는 패턴
### 기본 컴포넌트 구조
```typescript
import { useMemo } from 'react';
import { useHandle } from '@winglet/react-utils';
import type { FormTypeInputPropsWithSchema } from '@canard/schema-form';
interface FormTypeInput[Name]Props
extends FormTypeInputPropsWithSchema<ValueType, SchemaType, ContextType>,
ContextType {
// 컴포넌트별 추가 props
}
const FormTypeInput[Name] = ({
// Schema/Node 정보
jsonSchema,
node,
path,
name,
// 상태
required,
disabled,
readOnly,
errors,
// 값
defaultValue,
onChange,
// Context
context,
// 직접 props (Context에서도 올 수 있음)
size: sizeProp,
variant: variantProp,
// 추가 props
placeholder,
}: FormTypeInput[Name]Props) => {
// 1. useMemo로 props 우선순위 적용
const [size, variant, label] = useMemo(() => {
return [
sizeProp || context.size || 'medium',
variantProp || context.variant || 'standard',
jsonSchema.label || jsonSchema.title || name,
];
}, [sizeProp, context, jsonSchema, variantProp, name]);
// 2. useHandle로 이벤트 핸들러 최적화
const handleChange = useHandle((event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
});
// 3. 비제어 컴포넌트 패턴 사용
return (
<[UILibraryComponent]
id={path}
name={name}
required={required}
disabled={disabled || readOnly}
defaultValue={defaultValue} // ✅ 비제어
onChange={handleChange}
size={size}
variant={variant}
placeholder={placeholder || jsonSchema.placeholder}
error={errors.length > 0}
/>
);
};
export const FormTypeInput[Name]Definition = {
Component: FormTypeInput[Name],
test: { type: 'string' }, // 또는 함수 형태
} satisfies FormTypeInputDefinition;
```
### 플러그인 구조
```typescript
// src/index.ts
import type { SchemaFormPlugin } from '@canard/schema-form';
import { FormError } from './components/FormError';
import { FormGroup } from './components/FormGroup';
import { FormInput } from './components/FormInput';
import { FormLabel } from './components/FormLabel';
import { formatError } from './components/formatError';
import { formTypeInputDefinitions } from './formTypeInputs';
export const plugin = {
FormGroup,
FormLabel,
FormInput,
FormError,
formatError,
formTypeInputDefinitions,
} satisfies SchemaFormPlugin;
export type * from './type';
export * from './formTypeInputs';
```
### formTypeInputDefinitions 우선순위
```typescript
// src/formTypeInputs/index.ts
import type { FormTypeInputDefinition } from '@canard/schema-form';
// ⚠️ 순서가 매우 중요! 구체적 조건이 앞에 와야 함
export const formTypeInputDefinitions: FormTypeInputDefinition[] = [
// === Phase 1: 가장 구체적 (format + formType 등) ===
FormTypeInputPasswordDefinition, // type: string, format: password
FormTypeInputTextareaDefinition, // type: string, format: textarea
FormTypeInputSliderDefinition, // type: number, formType: slider
FormTypeInputRadioGroupDefinition, // type: string, formType: radio
// === Phase 2: 중간 (format 또는 formType) ===
FormTypeInputDateDefinition, // type: string, format: date
FormTypeInputTimeDefinition, // type: string, format: time
// === Phase 3: Enum 조건 (함수 형태) ===
FormTypeInputStringEnumDefinition, // type: string, enum exists
FormTypeInputNumberEnumDefinition, // type: number, enum exists
// === Phase 4: 구조 타입 ===
FormTypeInputArrayDefinition, // type: array
FormTypeInputObjectDefinition, // type: object
// === Phase 5: 일반 타입 (마지막) ===
FormTypeInputNumberDefinition, // type: number | integer
FormTypeInputBooleanDefinition, // type: boolean
FormTypeInputStringDefinition, // type: string (가장 일반적 - 마지막!)
];
```
**우선순위 원칙**:
1. 특수 조건 (format + formType) 먼저
2. 중간 조건 (format 또는 formType)
3. Enum 조건 (복합 함수)
4. 구조 타입 (array, object)
5. 일반 타입 (type만) 마지막
## 특수 패턴
### Array 컴포넌트 (ChildNodeComponents 사용)
```typescript
const FormTypeInputArray = ({
jsonSchema,
path,
ChildNodeComponents, // ✅ canard-form이 제공
context,
}: FormTypeInputArrayProps) => {
return (
<Box>
{ChildNodeComponents.map((ChildComponent, index) => (
<Box key={index} sx={{ mb: 2 }}>
<ChildComponent /> {/* ✅ 자동으로 props 전달됨 */}
</Box>
))}
{/* 추가/제거 버튼만 UI 스타일링 */}
<Button onClick={/* add item */}>Add</Button>
</Box>
);
};
```
### 래퍼 컴포넌트 패턴 (DatePicker 등)
```typescript
const FormTypeInputDate = ({
defaultValue,
onChange,
...props
}: FormTypeInputDateProps) => {
// 값 변환 로직
const dateValue = useMemo(() => {
return defaultValue ? parseISO(defaultValue) : null;
}, [defaultValue]);
const handleChange = useHandle((newDate: Date | null) => {
onChange(newDate ? formatISO(newDate) : '');
});
return (
<LocalizationProvider dateAdapter={AdapterDateFns}>
<DatePicker
value={dateValue}
onChange={handleChange}
slotProps={{
textField: {
id: props.path,
name: props.name,
required: props.required,
disabled: props.disabled,
},
}}
/>
</LocalizationProvider>
);
};
```
## 성능 최적화 체크리스트
- [ ] 비제어 컴포넌트 패턴 적용 (`defaultValue` 사용)
- [ ] `useMemo`로 props 연산 최적화
- [ ] `useHandle`로 이벤트 핸들러 메모이제이션
- [ ] Context 구독 최소화
- [ ] 조건부 렌더링 최적화
- [ ] ChildNodeComponents는 직접 렌더링 (props 전파 금지)
## 제약 조건
- 제어 컴포넌트는 **반드시 필요한 경우에만** 사용 (실시간 validation, 조건부 렌더링 등)
- `useHandle` 훅은 `@winglet/react-utils`에서 import
- `satisfies FormTypeInputDefinition`으로 타입 검증 필수
- formTypeInputDefinitions 배열 순서 변경 시 반드시 우선순위 검토
## 출력 형식
### 구현 코드 제공 시
```markdown
## FormTypeInput[Name] 구현
**매핑 조건**: [조건 설명]
**구현 코드**:
[TypeScript 코드]
**최적화 포인트**:
- [최적화 1]
- [최적화 2]
**주의사항**:
- [주의사항 1]
```
## 사용 시나리오
### 시나리오 1: FormTypeInputString 기본 구현
**상황**: MUI TextField를 사용한 가장 기본적인 문자열 입력 컴포넌트 구현
**단계**:
#### Step 1: 타입 정의 및 기본 구조
```typescript
// src/formTypeInputs/FormTypeInputString.tsx
import { useMemo } from 'react';
import { useHandle } from '@winglet/react-utils';
import type { FormTypeInputPropsWithSchema } from '@canard/schema-form';
import { TextField } from '@mui/material';
import type { TextFieldProps } from '@mui/material';
// 1️⃣ Context 타입 정의 (plugin의 공통 context)
interface MuiFormContext {
size?: 'small' | 'medium' | 'large';
variant?: 'standard' | 'outlined' | 'filled';
}
// 2️⃣ Component Props = FormTypeInputPropsWithSchema + Context + 추가 props
interface FormTypeInputStringProps
extends FormTypeInputPropsWithSchema<string, never, MuiFormContext>,
MuiFormContext {
placeholder?: string;
multiline?: boolean;
rows?: number;
}
// 3️⃣ 컴포넌트 구현
const FormTypeInputString = ({
// Schema/Node 정보
jsonSchema,
node,
path,
name,
// 상태
required,
disabled,
readOnly,
errors,
// 값 (비제어 컴포넌트)
defaultValue,
onChange,
// Context
context,
// 직접 props (우선순위 높음)
size: sizeProp,
variant: variantProp,
// 추가 props
placeholder,
multiline,
rows,
}: FormTypeInputStringProps) => {
// 4️⃣ useMemo로 props 우선순위 적용 (직접 props > context > 기본값)
const [size, variant, label] = useMemo(() => {
return [
sizeProp ?? context.size ?? 'medium',
variantProp ?? context.variant ?? 'outlined',
jsonSchema.label ?? jsonSchema.title ?? name,
];
}, [sizeProp, context.size, variantProp, context.variant, jsonSchema.label, jsonSchema.title, name]);
// 5️⃣ 에러 처리
const hasError = errors.length > 0;
const errorMessage = hasError ? errors[0].message : undefined;
// 6️⃣ useHandle로 이벤트 핸들러 최적화
const handleChange = useHandle((event: React.ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
});
// 7️⃣ 비제어 컴포넌트 렌더링
return (
<TextField
id={path}
name={name}
label={label}
required={required}
disabled={disabled || readOnly}
defaultValue={defaultValue ?? ''} // ✅ 비제어 컴포넌트
onChange={handleChange}
size={size}
variant={variant}
placeholder={placeholder ?? jsonSchema.placeholder}
error={hasError}
helperText={errorMessage ?? jsonSchema.description}
multiline={multiline}
rows={rows}
fullWidth
/>
);
};
// 8️⃣ Definition 객체 (test 조건 포함)
export const FormTypeInputStringDefinition = {
Component: FormTypeInputString,
test: { type: 'string' }, // type이 'string'일 때 매칭
} satisfies FormTypeInputDefinition;
```
#### Step 2: 최적화 포인트
```typescript
// ✅ GOOD: useMemo로 props 연산 캐싱
const [size, variant, label] = useMemo(() => {
return [
sizeProp ?? context.size ?? 'medium',
variantProp ?? context.variant ?? 'outlined',
jsonSchema.label ?? jsonSchema.title ?? name,
];
}, [sizeProp, context.size, variantProp, context.variant, jsonSchema.label, jsonSchema.title, name]);
// ❌ BAD: 매 렌더링마다 연산
const size = sizeProp ?? context.size ?? 'medium';
const variant = variantProp ?? context.variant ?? 'outlined';
// ✅ GOOD: useHandle로 이벤트 핸들러 메모이제이션
const handleChange = useHandle((event: React.ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
});
// ❌ BAD: 매 렌더링마다 새 함수 생성
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
};
```
#### Step 3: 테스트 작성
```typescript
// src/formTypeInputs/__tests__/FormTypeInputString.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormTypeInputString } from '../FormTypeInputString';
describe('FormTypeInputString', () => {
it('renders with defaultValue', () => {
render(
<FormTypeInputString
jsonSchema={{ type: 'string' }}
path="test"
name="test"
defaultValue="initial value"
onChange={jest.fn()}
required={false}
disabled={false}
readOnly={false}
errors={[]}
context={{}}
/>
);
expect(screen.getByDisplayValue('initial value')).toBeInTheDocument();
});
it('calls onChange when user types', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
render(
<FormTypeInputString
jsonSchema={{ type: 'string' }}
path="test"
name="test"
onChange={onChange}
required={false}
disabled={false}
readOnly={false}
errors={[]}
context={{}}
/>
);
await user.type(screen.getByRole('textbox'), 'hello');
expect(onChange).toHaveBeenCalled();
});
});
```
**결과**: 기본 문자열 입력 컴포넌트 완성, 비제어 패턴으로 성능 최적화
---
### 시나리오 2: FormTypeInputDate (값 변환 패턴)
**상황**: MUI DatePicker를 사용한 날짜 입력 컴포넌트, ISO 8601 문자열 ↔ Date 객체 변환 필요
**단계**:
#### Step 1: 날짜 변환 로직 구현
```typescript
// src/formTypeInputs/FormTypeInputDate.tsx
import { useMemo } from 'react';
import { useHandle } from '@winglet/react-utils';
import type { FormTypeInputPropsWithSchema } from '@canard/schema-form';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { parseISO, formatISO } from 'date-fns';
import type { MuiFormContext } from '../type';
interface FormTypeInputDateProps
extends FormTypeInputPropsWithSchema<string, never, MuiFormContext>,
MuiFormContext {
minDate?: string;
maxDate?: string;
}
const FormTypeInputDate = ({
jsonSchema,
path,
name,
required,
disabled,
readOnly,
errors,
defaultValue, // ⚠️ ISO 8601 문자열 (예: "2024-01-15T00:00:00.000Z")
onChange, // ⚠️ ISO 8601 문자열로 전달해야 함
context,
size: sizeProp,
variant: variantProp,
minDate,
maxDate,
}: FormTypeInputDateProps) => {
// 1️⃣ ISO 문자열 → Date 객체 변환
const dateValue = useMemo(() => {
if (!defaultValue) return null;
try {
return parseISO(defaultValue);
} catch {
return null;
}
}, [defaultValue]);
// 2️⃣ minDate, maxDate도 변환
const [minDateObj, maxDateObj] = useMemo(() => {
return [
minDate ? parseISO(minDate) : undefined,
maxDate ? parseISO(maxDate) : undefined,
];
}, [minDate, maxDate]);
// 3️⃣ Date 객체 → ISO 문자열 변환 핸들러
const handleChange = useHandle((newDate: Date | null) => {
if (newDate === null) {
onChange('');
return;
}
try {
const isoString = formatISO(newDate);
onChange(isoString);
} catch {
onChange('');
}
});
const [size, variant, label] = useMemo(() => {
return [
sizeProp ?? context.size ?? 'medium',
variantProp ?? context.variant ?? 'outlined',
jsonSchema.label ?? jsonSchema.title ?? name,
];
}, [sizeProp, context.size, variantProp, context.variant, jsonSchema.label, jsonSchema.title, name]);
const hasError = errors.length > 0;
const errorMessage = hasError ? errors[0].message : undefined;
// 4️⃣ DatePicker 렌더링
return (
<LocalizationProvider dateAdapter={AdapterDateFns}>
<DatePicker
label={label}
value={dateValue} // ✅ Date 객체
onChange={handleChange} // ✅ Date 객체 받아서 ISO 문자열로 변환
minDate={minDateObj}
maxDate={maxDateObj}
disabled={disabled || readOnly}
slotProps={{
textField: {
id: path,
name: name,
required: required,
size: size,
variant: variant,
error: hasError,
helperText: errorMessage ?? jsonSchema.description,
fullWidth: true,
},
}}
/>
</LocalizationProvider>
);
};
// 5️⃣ Definition 객체 (format: 'date' 조건)
export const FormTypeInputDateDefinition = {
Component: FormTypeInputDate,
test: { type: 'string', format: 'date' }, // type: string + format: date
} satisfies FormTypeInputDefinition;
```
#### Step 2: 에러 처리 및 유효성 검증
```typescript
// date-fns의 isValid 사용
import { parseISO, formatISO, isValid } from 'date-fns';
const handleChange = useHandle((newDate: Date | null) => {
if (newDate === null) {
onChange('');
return;
}
// ✅ Date 유효성 검증
if (!isValid(newDate)) {
console.warn('Invalid date:', newDate);
onChange('');
return;
}
try {
const isoString = formatISO(newDate);
onChange(isoString);
} catch (error) {
console.error('Date conversion error:', error);
onChange('');
}
});
```
#### Step 3: 타임존 처리 (선택)
```typescript
// UTC 고정 변환이 필요한 경우
import { parseISO, formatISO } from 'date-fns';
import { zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz';
const handleChange = useHandle((newDate: Date | null) => {
if (newDate === null) {
onChange('');
return;
}
// 사용자 로컬 시간 → UTC 변환
const utcDate = zonedTimeToUtc(newDate, Intl.DateTimeFormat().resolvedOptions().timeZone);
const isoString = formatISO(utcDate);
onChange(isoString);
});
```
**결과**: ISO 8601 문자열 ↔ Date 객체 변환이 완벽히 처리된 DatePicker 컴포넌트
**주의사항**:
- `@canard/schema-form`은 항상 ISO 8601 문자열로 값을 주고받음
- Date 객체는 컴포넌트 내부에서만 사용, onChange에는 반드시 문자열 전달
- `date-fns`의 `parseISO`, `formatISO` 사용 권장 (타임존 안전)
---
### 시나리오 3: FormTypeInputStringEnum (함수 형태 test 조건)
**상황**: Enum 값을 가진 문자열을 Select 또는 RadioGroup으로 표시
**단계**:
#### Step 1: 복합 조건 test 함수 작성
```typescript
// src/formTypeInputs/FormTypeInputStringEnum.tsx
import { useMemo } from 'react';
import { useHandle } from '@winglet/react-utils';
import type { FormTypeInputPropsWithSchema, FormTypeTestFn } from '@canard/schema-form';
import { FormControl, FormLabel, RadioGroup, FormControlLabel, Radio, FormHelperText } from '@mui/material';
import type { MuiFormContext } from '../type';
interface FormTypeInputStringEnumProps
extends FormTypeInputPropsWithSchema<string, never, MuiFormContext>,
MuiFormContext {}
const FormTypeInputStringEnum = ({
jsonSchema,
path,
name,
required,
disabled,
readOnly,
errors,
defaultValue,
onChange,
context,
size: sizeProp,
}: FormTypeInputStringEnumProps) => {
// 1️⃣ enum 값 추출 (jsonSchema.enum은 배열)
const enumValues = useMemo(() => {
return jsonSchema.enum as string[] ?? [];
}, [jsonSchema.enum]);
// 2️⃣ enum 라벨 매핑 (enumLabels가 있으면 사용)
const enumLabels = useMemo(() => {
if (jsonSchema.enumLabels) {
return jsonSchema.enumLabels as string[];
}
// enumLabels가 없으면 enum 값 그대로 사용
return enumValues;
}, [jsonSchema.enumLabels, enumValues]);
const handleChange = useHandle((event: React.ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
});
const [size, label] = useMemo(() => {
return [
sizeProp ?? context.size ?? 'medium',
jsonSchema.label ?? jsonSchema.title ?? name,
];
}, [sizeProp, context.size, jsonSchema.label, jsonSchema.title, name]);
const hasError = errors.length > 0;
const errorMessage = hasError ? errors[0].message : undefined;
// 3️⃣ RadioGroup 렌더링
return (
<FormControl error={hasError} required={required} disabled={disabled || readOnly} size={size}>
<FormLabel id={`${path}-label`}>{label}</FormLabel>
<RadioGroup
aria-labelledby={`${path}-label`}
name={name}
defaultValue={defaultValue ?? ''}
onChange={handleChange}
>
{enumValues.map((value, index) => (
<FormControlLabel
key={value}
value={value}
control={<Radio />}
label={enumLabels[index] ?? value}
/>
))}
</RadioGroup>
{(hasError || jsonSchema.description) && (
<FormHelperText>{errorMessage ?? jsonSchema.description}</FormHelperText>
)}
</FormControl>
);
};
// 4️⃣ 함수 형태 test 조건 (복합 조건)
const testStringEnum: FormTypeTestFn = ({ jsonSchema }) => {
// type이 'string'이고 enum이 있는 경우에만 true
return (
jsonSchema.type === 'string' &&
Array.isArray(jsonSchema.enum) &&
jsonSchema.enum.length > 0
);
};
export const FormTypeInputStringEnumDefinition = {
Component: FormTypeInputStringEnum,
test: testStringEnum, // ✅ 함수 형태
} satisfies FormTypeInputDefinition;
```
#### Step 2: Select 버전 (대안)
```typescript
// Select를 사용하는 버전 (많은 옵션일 때 유리)
import { FormControl, InputLabel, Select, MenuItem, FormHelperText } from '@mui/material';
const FormTypeInputStringEnumSelect = ({
jsonSchema,
path,
name,
required,
disabled,
readOnly,
errors,
defaultValue,
onChange,
context,
size: sizeProp,
variant: variantProp,
}: FormTypeInputStringEnumProps) => {
const enumValues = useMemo(() => {
return jsonSchema.enum as string[] ?? [];
}, [jsonSchema.enum]);
const enumLabels = useMemo(() => {
if (jsonSchema.enumLabels) {
return jsonSchema.enumLabels as string[];
}
return enumValues;
}, [jsonSchema.enumLabels, enumValues]);
const handleChange = useHandle((event: SelectChangeEvent<string>) => {
onChange(event.target.value);
});
const [size, variant, label] = useMemo(() => {
return [
sizeProp ?? context.size ?? 'medium',
variantProp ?? context.variant ?? 'outlined',
jsonSchema.label ?? jsonSchema.title ?? name,
];
}, [sizeProp, context.size, variantProp, context.variant, jsonSchema.label, jsonSchema.title, name]);