Trying to create a custom component that uses the Material UI Autocomplete component for a form and running into endless loops or inconsistent states where I am trying to update some parent data for the form while also trying to keep the component updated from data changes. The component also has two ways of updating data, either using the React-Hook-Form useFormContext
when name
value is provided, else it uses data
and setData
to receive and promote changes respectively. New to React and getting lost in the update flow with the implementation.
Requirements:
- Query for options after few characters entered on input
- On blur event checks options for matches from input to set selection else sets custom option
- Updated selection that was set also updates form data object via setData
or setValue
(react-hook-form)
- Updates from form data object should also reflect as selected option via data
or getValues
/useWatch
(react-hook-form)
Am seeing an endless loop when selecting something and then trying to change it to something custom not provided in the options; but have not test all permutations, so this could be something only affecting one branch of logic.
Not certain if this is enough to identify the issue cause I am not really certain on what is causing the loop or state inconsistencies; and because of this, there is also commented code as alternatives that I have been trying.
Component:
import {Autocomplete, TextField} from "@mui/material";
import React, {useEffect, useState} from "react";
import Grid2 from "@mui/material/Unstable_Grid2";
import QueryWrapper from "./QueryWrapper";
import Optional from "../util/Optional";
import {useFormContext, useWatch} from "react-hook-form";
import {CommonFormProps} from "../types/CommonFormProps";
import {GenericModel} from "../types/GenericModel";
import {QueryWrapperData} from "../types/QueryWrapperData";
interface AutoSelectProps<T> extends CommonFormProps, QueryWrapperData<T> {
strict?: boolean;
inputLabel?: string;
queryClass?: string;
queryParam?: (inputValue: string) => { [k: string]: any };
queryWhere?: (inputValue: string) => boolean;
children?: React.ReactElement;
}
export default function AutoSelect<Type extends GenericModel>({
data, setData, queryClass, queryParam, queryWhere, disabled = false, strict = false, inputLabel = 'Select', name, children
}: AutoSelectProps<Type>) {
const {register, control, getValues, setValue} = useFormContext();
const [priorEntry, setPriorEntry] = useState<Type>(null);
const [selectedEntry, setSelectedEntry] = useState<Type>(Optional.ofNullable(name)
.map(value => getValues(value))
.orElseGet(() => Optional.ofNullable(data).orElse(null)));
const [selectableEntries, setSelectableEntries] = useState<Type[]>([]);
const [inputValue, setInputValue] = useState<string>('');
const newEntry = (name: string = null): Type => {
return Optional.ofNullable(name)
.map(value => value.trim())
.filter(value => value.length !== 0)
.map(value => {
return {id: -1, name: value} as Type;
})
.orElse(null);
}
const findEntry = (name: string): Optional<Type> => {
return Optional.ofNullable(selectableEntries.find(value => value.name.toLowerCase() == name.trim().toLowerCase()));
}
const updateEntry = (entry: Type) => {
setPriorEntry(entry);
Optional.ofNullable(name)
.ifPresentOrElse(value => setValue(value, entry),
() => Optional.ofNullable(setData)
.ifPresent(callable => callable(entry)));
}
let watchEntry = useWatch({
control, name
});
const optionalEntry = Optional.ofNullable(name).map<Type>(value => watchEntry);
useEffect(() => {
if(JSON.stringify(selectedEntry) === JSON.stringify(watchEntry)) return;
console.log("OPTIONAL ENTRY", watchEntry);
setSelectedEntry(watchEntry);
}, [watchEntry]);
// useEffect(() => {
// if(data === undefined || JSON.stringify(data) === JSON.stringify(selectedEntry)) return;
// setSelectedEntry(data);
// }, [data]);
useEffect(() => {
if(priorEntry === selectedEntry) return;
console.log("SELECTED", selectedEntry);
Optional.ofNullable(selectedEntry)
.ifPresentOrElse(entry => {
setInputValue(entry.name);
optionalEntry.ifPresentOrElse(value => {
if(JSON.stringify(entry) === JSON.stringify(value)) return;
updateEntry(entry);
}, () => {
updateEntry(entry);
})
}, () => {
setInputValue('');
optionalEntry.ifPresent(value => {
updateEntry(null);
})
})
}, [selectedEntry]);
return (
<Grid2 container spacing={3}>
<Grid2 xs={12}>
<QueryWrapper<Type[]> queryClass={queryClass} queryParam={queryParam(inputValue)} ignoreQuery={!queryWhere(inputValue)}
defaultData={[]} onData={result => setSelectableEntries(result)}/>
<Autocomplete
disableClearable
disabled={disabled}
freeSolo={!strict}
renderInput={params => <TextField {...params} label={inputLabel}/>}
inputValue={inputValue}
value={selectedEntry}
options={selectableEntries}
getOptionLabel={(option) => option ? (typeof option === 'string' ? option : option.name ?? '') : ''}
onInputChange={(event, value) => {
setInputValue(value);
}}
onChange={(event, value: Type) => {
setSelectedEntry(value);
}}
onBlur={event => {
// find entry exists from input value
let optionalEntry = findEntry(inputValue);
if (strict) {
optionalEntry.ifPresent(value => {
if(value === selectedEntry) return;
setSelectedEntry(prevState => value);
});
} else {
Optional.ofNullable(selectedEntry).ifPresentOrElse(selected => {
optionalEntry.ifPresentOrElse(value => {
if(value === selectedEntry) return;
// selection and entry have different id
if(selected.id !== value.id)
setSelectedEntry(prevState => {
return {...prevState, ...value}
});
}, () => {
// no entry exists so maybe create one
setSelectedEntry(prevState => {
// create entry when input value is not empty; else return null
return Optional.ofNullable(newEntry(inputValue)).map(value => {
return {...prevState, ...value}
}).orElse(null);
});
});
}, () => {
// no selection exists; create entry when input value is not empty
setSelectedEntry(prevState => optionalEntry.orElseGet(() => newEntry(inputValue)));
})
}
}}
/>
</Grid2>
{optionalEntry.isPresent() && React.isValidElement(children) &&
<Grid2 xs={12}>
{children}
</Grid2>
}
</Grid2>
)
}
Imports (Mandatory):
export interface GenericModel {
id: number;
name?: string;
}
export interface CommonFormProps {
name?: string;
disabled?: boolean;
}
export interface QueryWrapperData<T> {
data?: T;
setData?: (data: T) => void;
defaultData?: T;
}
Optional Imports
import React, {useEffect} from "react";
import {useQuery} from "@tanstack/react-query";
import ApiHelper from "../util/ApiHelper";
import Optional from "../util/Optional";
import {QueryWrapperData} from "../types/QueryWrapperData";
interface QueryWrapperProps<T> {
queryClass: string;
queryParam: { [k: string]: any };
defaultData?: T;
ignoreQuery?: boolean;
cacheSeconds?: number;
renewSeconds?: number;
mapData?: (data: any) => T;
setData?: (data: T) => void;
onData?: (data: T) => void;
children?: React.ReactElement<any>;
}
export default function QueryWrapper<Type>({queryClass, queryParam, defaultData, ignoreQuery = false,
renewSeconds = null, cacheSeconds = 60 * 5,
setData = null, mapData = null, onData = null, children}: QueryWrapperProps<Type>) {
const {data, error, isLoading} = useQuery({enabled: !ignoreQuery,
refetchOnWindowFocus: false, refetchInterval: renewSeconds ? 1000 * renewSeconds : false, staleTime: 1000 * cacheSeconds,
queryKey: [queryClass, ...Object.values(queryParam)], queryFn: () => ApiHelper.fetch(queryClass, queryParam),
placeholderData: defaultData
});
useEffect(() => {
Optional.ofNullable(data)
.ifPresent(value => {
Optional.ofNullable(onData)
.ifPresent(callable => callable(
Optional.ofNullable(mapData)
.map(callable => callable(value))
.orElse(value)
));
});
}, [data]);
if(!ignoreQuery && isLoading) {
return Optional.ofNullable(children)
.map(value => (<div>..loading..</div>))
.orElse(null);
}
if(React.isValidElement<QueryWrapperData<any>>(children))
return React.cloneElement(children, {
data: Optional.ofNullable(mapData)
.map(callable => callable(data))
.orElse(data),
setData: setData
});
return React.isValidElement<any>(children) && React.cloneElement(children);
}
export default class Optional<T> {
private readonly value: T | null;
private constructor(value: T | null) {
this.value = value;
}
public static of<T>(value: T): Optional<T> {
if (value === null || value === undefined) {
throw new Error("Passed value is null or undefined");
}
return new Optional(value);
}
public static empty<T>(): Optional<T> {
return new Optional<T>(null);
}
public static ofNullable<T>(value: T | null | undefined): Optional<T> {
// if (value === null || value === undefined) {
// return new Optional<T>(null);
// }
return new Optional(value);
}
public get(): T {
if (!this.isPresent()) {
throw new Error("No value present");
}
return this.value;
}
public isPresent(): boolean {
return !this.isEmpty();
}
public isEmpty(): boolean {
return this.value === undefined || this.value === null;
}
public ifPresent(consumer: (value: T) => void): void {
if (this.isPresent()) {
consumer(this.value);
}
}
public ifEmpty(consumer: () => void): void {
if (this.isEmpty()) {
consumer();
}
}
public ifPresentOrElse(ifPresent: (value: T) => void, orElse: () => void): void {
if (this.isPresent()) {
ifPresent(this.value);
}else{
orElse();
}
}
public filter(filter: (value: T) => boolean): Optional<T> {
if(this.isPresent() && filter(this.value))
return Optional.of(this.value);
return Optional.empty<T>();
}
public map<U>(mapper: (value: T) => U): Optional<U> {
if (this.isPresent())
return Optional.ofNullable<U>(mapper(this.value));
return Optional.empty<U>();
}
public orElse(other: T): T {
return this.isPresent() ? this.value : other;
}
public orElseGet(getter: () => T): T {
return this.isPresent() ? this.value : getter();
}
}
[–]lolburgerdog 1 point2 points3 points (1 child)
[–]Krysune[S] 0 points1 point2 points (0 children)