Skip to content

Commit

Permalink
feat(conform-dom,conform-react,conform-yup,conform-zod)!: make intern…
Browse files Browse the repository at this point in the history
…al state uncontrolled (#39)

* feat!: make internal state uncontrolled

* fix: only defaultValue and initialError should be uncontrolled
  • Loading branch information
edmundhung committed Oct 3, 2022
1 parent df42ba3 commit 48dfbee
Show file tree
Hide file tree
Showing 8 changed files with 171 additions and 106 deletions.
2 changes: 1 addition & 1 deletion examples/remix/app/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default function OrderForm() {
formProps.ref,
{
defaultValue: formState?.value,
initialError: formState?.error.details,
initialError: formState?.error,
},
);
const [taskList, control] = useFieldList(formProps.ref, tasks.config);
Expand Down
23 changes: 5 additions & 18 deletions packages/conform-dom/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type FieldElement =
export interface FieldConfig<Schema = unknown> extends FieldConstraint {
name: string;
defaultValue?: FieldValue<Schema>;
initialError?: FieldError<Schema>['details'];
initialError?: Array<[string, string]>;
form?: string;
}

Expand All @@ -21,17 +21,6 @@ export type FieldValue<Schema> = Schema extends Primitive | File
? { [Key in keyof Schema]?: FieldValue<Schema[Key]> }
: unknown;

export interface FieldError<Schema> {
message?: string;
details?: Schema extends Primitive | File
? never
: Schema extends Array<infer InnerType>
? Array<FieldError<InnerType>>
: Schema extends Record<string, any>
? { [Key in keyof Schema]?: FieldError<Schema[Key]> }
: unknown;
}

export type FieldConstraint = {
required?: boolean;
minLength?: number;
Expand Down Expand Up @@ -59,7 +48,7 @@ export type Schema<Shape extends Record<string, any>, Source> = {

export interface FormState<Schema extends Record<string, any>> {
value: FieldValue<Schema>;
error: FieldError<Schema>;
error: Array<[string, string]>;
}

export type Submission<T extends Record<string, unknown>> =
Expand Down Expand Up @@ -248,7 +237,7 @@ export function createSubmission(
state: 'modified',
form: {
value,
error: {},
error: [],
},
};
}
Expand All @@ -257,9 +246,7 @@ export function createSubmission(
state: 'rejected',
form: {
value,
error: {
message: e instanceof Error ? e.message : 'Submission failed',
},
error: [['', e instanceof Error ? e.message : 'Submission failed']],
},
};
}
Expand All @@ -269,7 +256,7 @@ export function createSubmission(
data: value,
form: {
value,
error: {},
error: [],
},
};
}
Expand Down
186 changes: 143 additions & 43 deletions packages/conform-react/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
type FieldConfig,
type FieldError,
type FieldValue,
type FieldElement,
type FieldsetConstraint,
Expand All @@ -13,6 +12,8 @@ import {
parseListCommand,
updateList,
getFormElement,
getPaths,
getName,
} from '@conform-to/dom';
import {
type InputHTMLAttributes,
Expand Down Expand Up @@ -265,7 +266,7 @@ export interface FieldsetConfig<Schema extends Record<string, any>> {
/**
* An object describing the initial error of each field
*/
initialError?: FieldError<Schema>['details'];
initialError?: Array<[string, string]>;

/**
* An object describing the constraint of each field
Expand Down Expand Up @@ -295,18 +296,57 @@ export function useFieldset<Schema extends Record<string, any>>(
ref: RefObject<HTMLFormElement | HTMLFieldSetElement>,
config?: FieldsetConfig<Schema> | FieldConfig<Schema>,
): Fieldset<Schema> {
const configRef = useRef(config);
const [uncontrolledState, setUncontrolledState] = useState<{
defaultValue: FieldValue<Schema>;
initialError: Record<string, Array<[string, string]> | undefined>;
}>(
// @ts-expect-error
() => {
const initialError: Record<string, Array<[string, string]> | undefined> =
{};

for (const [name, message] of config?.initialError ?? []) {
const [key, ...paths] = getPaths(name);

if (typeof key === 'string') {
const scopedName = getName(paths);
const entries = initialError[key] ?? [];

if (scopedName === '' && entries.length > 0 && entries[0][0] !== '') {
initialError[key] = [[scopedName, message], ...entries];
} else {
initialError[key] = [...entries, [scopedName, message]];
}
}
}

return {
defaultValue: config?.defaultValue ?? {},
initialError,
};
},
);
const [error, setError] = useState<Record<string, string | undefined>>(() => {
const result: Record<string, string> = {};

for (const [key, error] of Object.entries(config?.initialError ?? {})) {
if (error?.message) {
result[key] = error.message;
for (const [key, entries] of Object.entries(
uncontrolledState.initialError,
)) {
const [name, message] = entries?.[0] ?? [];

if (name === '') {
result[key] = message ?? '';
}
}

return result;
});

useEffect(() => {
configRef.current = config;
});

useEffect(() => {
/**
* Reset the error state of each field if its validity is changed.
Expand All @@ -320,7 +360,7 @@ export function useFieldset<Schema extends Record<string, any>>(

for (const field of form.elements) {
if (isFieldElement(field)) {
const key = getKey(field.name, config?.name);
const key = getKey(field.name, configRef.current?.name);

if (key) {
const prevMessage = next?.[key] ?? '';
Expand Down Expand Up @@ -362,7 +402,7 @@ export function useFieldset<Schema extends Record<string, any>>(
return;
}

const key = getKey(field.name, config?.name);
const key = getKey(field.name, configRef.current?.name);

// Update the error only if the field belongs to the fieldset
if (key) {
Expand Down Expand Up @@ -399,6 +439,15 @@ export function useFieldset<Schema extends Record<string, any>>(
return;
}

const fieldsetConfig = configRef.current as
| FieldsetConfig<Schema>
| undefined;

setUncontrolledState({
// @ts-expect-error
defaultValue: fieldsetConfig?.defaultValue ?? {},
initialError: {},
});
setError({});
};

Expand All @@ -414,24 +463,7 @@ export function useFieldset<Schema extends Record<string, any>>(
document.removeEventListener('submit', submitHandler);
document.removeEventListener('reset', resetHandler);
};
}, [ref, config?.name]);

useEffect(() => {
setError((prev) => {
let next = prev;

for (const [key, error] of Object.entries(config?.initialError ?? {})) {
if (next[key] !== error?.message) {
next = {
...next,
[key]: error?.message ?? '',
};
}
}

return next;
});
}, [config?.name, config?.initialError]);
}, [ref]);

/**
* This allows us constructing the field at runtime as we have no information
Expand All @@ -446,17 +478,14 @@ export function useFieldset<Schema extends Record<string, any>>(
return;
}

const constraint = (config as FieldsetConfig<Schema>)?.constraint?.[
key
];
const fieldsetConfig = (config ?? {}) as FieldsetConfig<Schema>;
const constraint = fieldsetConfig.constraint?.[key];
const field: Field<unknown> = {
config: {
name: config?.name ? `${config.name}.${key}` : key,
form: config?.form,
defaultValue: config?.defaultValue?.[key],
initialError:
config?.initialError?.[key]?.details ??
config?.initialError?.[key]?.message,
name: fieldsetConfig.name ? `${fieldsetConfig.name}.${key}` : key,
form: fieldsetConfig.form,
defaultValue: uncontrolledState.defaultValue[key],
initialError: uncontrolledState.initialError[key],
...constraint,
},
error: error?.[key] ?? '',
Expand Down Expand Up @@ -507,17 +536,44 @@ export function useFieldList<Payload = any>(
}>,
ListControl<Payload>,
] {
const configRef = useRef(config);
const [uncontrolledState, setUncontrolledState] = useState<{
defaultValue: FieldValue<Array<Payload>>;
initialError: Array<Array<[string, string]> | undefined>;
}>(() => {
const initialError: Array<Array<[string, string]> | undefined> = [];

for (const [name, message] of config?.initialError ?? []) {
const [index, ...paths] = getPaths(name);

if (typeof index === 'number') {
const scopedName = getName(paths);
const entries = initialError[index] ?? [];

if (scopedName === '' && entries.length > 0 && entries[0][0] !== '') {
initialError[index] = [[scopedName, message], ...entries];
} else {
initialError[index] = [...entries, [scopedName, message]];
}
}
}

return {
defaultValue: config.defaultValue ?? [],
initialError,
};
});
const [entries, setEntries] = useState<
Array<[string, FieldValue<Payload> | undefined]>
>(() => Object.entries(config.defaultValue ?? [undefined]));
const list = entries.map<{ key: string; config: FieldConfig<Payload> }>(
([key, defaultValue], index) => ({
key,
config: {
...config,
name: `${config.name}[${index}]`,
defaultValue: defaultValue ?? config.defaultValue?.[index],
initialError: config.initialError?.[index]?.details,
form: config.form,
defaultValue: defaultValue ?? uncontrolledState.defaultValue[index],
initialError: uncontrolledState.initialError[index],
},
}),
);
Expand All @@ -542,6 +598,10 @@ export function useFieldList<Payload = any>(
},
) as ListControl<Payload>;

useEffect(() => {
configRef.current = config;
});

useEffect(() => {
const submitHandler = (event: SubmitEvent) => {
const form = getFormElement(ref.current);
Expand All @@ -557,7 +617,7 @@ export function useFieldList<Payload = any>(

const [name, command] = parseListCommand(event.submitter.value);

if (name !== config.name) {
if (name !== configRef.current.name) {
// Ensure the scope of the listener are limited to specific field name
return;
}
Expand Down Expand Up @@ -588,7 +648,13 @@ export function useFieldList<Payload = any>(
return;
}

setEntries(Object.entries(config.defaultValue ?? [undefined]));
const fieldConfig = configRef.current;

setUncontrolledState({
defaultValue: fieldConfig.defaultValue ?? [],
initialError: [],
});
setEntries(Object.entries(fieldConfig.defaultValue ?? [undefined]));
};

document.addEventListener('submit', submitHandler, true);
Expand All @@ -598,7 +664,7 @@ export function useFieldList<Payload = any>(
document.removeEventListener('submit', submitHandler, true);
document.removeEventListener('reset', resetHandler);
};
}, [ref, config.name, config.defaultValue]);
}, [ref]);

return [list, control];
}
Expand All @@ -625,10 +691,18 @@ interface InputControl<Element extends { focus: () => void }> {
export function useControlledInput<
Element extends { focus: () => void } = HTMLInputElement,
Schema extends Primitive = Primitive,
>(field: FieldConfig<Schema>): [ShadowInputProps, InputControl<Element>] {
>(config: FieldConfig<Schema>): [ShadowInputProps, InputControl<Element>] {
const ref = useRef<HTMLInputElement>(null);
const inputRef = useRef<Element>(null);
const [value, setValue] = useState<string>(`${field.defaultValue ?? ''}`);
const configRef = useRef(config);
const [uncontrolledState, setUncontrolledState] = useState<{
defaultValue?: FieldValue<Schema>;
initialError?: Array<[string, string]>;
}>({
defaultValue: config.defaultValue,
initialError: config.initialError,
});
const [value, setValue] = useState<string>(`${config.defaultValue ?? ''}`);
const handleChange: InputControl<Element>['onChange'] = (eventOrValue) => {
if (!ref.current) {
return;
Expand All @@ -650,6 +724,32 @@ export function useControlledInput<
event.preventDefault();
};

useEffect(() => {
configRef.current = config;
});

useEffect(() => {
const resetHandler = (event: Event) => {
const form = getFormElement(ref.current);

if (!form || event.target !== form) {
return;
}

setUncontrolledState({
defaultValue: configRef.current.defaultValue,
initialError: configRef.current.initialError,
});
setValue(`${configRef.current.defaultValue ?? ''}`);
};

document.addEventListener('reset', resetHandler);

return () => {
document.removeEventListener('reset', resetHandler);
};
}, []);

return [
{
ref,
Expand All @@ -667,7 +767,7 @@ export function useControlledInput<
onFocus() {
inputRef.current?.focus();
},
...input(field, { type: 'text' }),
...input({ ...config, ...uncontrolledState }, { type: 'text' }),
},
{
ref: inputRef,
Expand Down
Loading

0 comments on commit 48dfbee

Please sign in to comment.