I'm looking for some advice in refactoring the following React component:
const Block = () => {
const {blockId} = useParams();
const {register, control, handleSubmit} = useForm();
const isNewBlock = typeof blockId === 'undefined';
const [saveBlockMutation] = useSaveBlockMutation();
const [deleteBlockMutation] = useDeleteBlockMutation();
const {data, loading, error} = useGetBlockQuery({
skip: isNewBlock,
variables: {block_id: parseInt(blockId!)}
});
const saveBlock = (input: any /* todo: type it */) => {
saveBlockMutation({variables: {input: input}})
.then(result => {
if (result.data?.saveBlock) {
// show notification
}
})
};
const deleteBlock = (blockId: number) => {
deleteBlockMutation({variables: {block_id: blockId}})
.then(result => {
if (result.data?.deleteBlock) {
// show notification
}
})
}
return (
<LoaderHandler loading={loading} error={error}>
{!loading && (
<>
<Header text={data ? "Block: " + data.block.identifier : "Add Block"}>
<Button onClick={handleSubmit(saveBlock)}>Save</Button>
{!isNewBlock && (<Button onClick={() => deleteBlock(parseInt(blockId!))}>Delete</Button>)}
</Header>
<Form data={data} register={register} control={control}/>
</>
)}
</LoaderHandler>
)
}
This currently works fine, but I'll be adding a number of other components that should behave the exact same way:
get some ID from the URL
load some data
render a form
save mutation
delete mutation
save/delete buttons
I feel that everything in that list I should be able to extract into something more generic, except for the "render a form" part.
I'm having trouble determining what that "something" is. Maybe a HOC is suitable here? I would end up with something like:
const Block = (props: WithCrudProps) => {
// we only render a form here
}
export default withCRUD(
Block,
{
deleteMutation: DeleteBlockMutation,
saveMutation: SaveBlockMutation,
getQuery: GetBlockQuery,
// etc.
}
);
But that feels like it could get messy real fast. What is the "react way" to approach this?
I think it would be hard to have a good implementation for withCRUD, because of all relations here:
// you need param name to extract it from params:
const params = useParams();
const param = params[paramName];
// then you need to convert param to query variables:
const queryVariables = makeQueryVariables(param)
// and you will need more of that for mutations
So I would recommend custom hook
interface UseCrudParams<T, Vars> {
id?: number;
initialData?: T;
onSave: (vars: Vars) => Promise<void>;
onDelete: () => Promise<void>
}
function useCrud<T, Vars>({
id,
initalData,
onSave,
onDelete,
}: UseCrudParams<T, Vars>): CrudProps { /* ... */}
// and then when you use it you adapt mutations to form
const formProps = useCrud({
id: blockId,
initialData: data,
onSave: variables => saveBlockMutation({ variables }),
onDelete: () => deleteBlockMutation({ variables: { block_id: blockId } }),
})
And create UI component for form layout:
function FormLayout({ header, loading, error, onSave, showDelete, onDelete, children }: FormLayoutProps) {
return (
<LoaderHandler loading={loading} error={error}>
{!loading && (
<>
<Header text={header}>
<Button onClick={onSave}>Save</Button>
{showDelete && (<Button onClick={onDelete}>Delete</Button>)}
</Header>
{children}
</>
)}
</LoaderHandler>
)
}
Related
Update
This maybe a Formik bug, and I have switched to https://react-hook-form.com, as Formik has not been updated for a while.
https://github.com/jaredpalmer/formik/issues/3716
Context
I'm using React, Formik, and google-map-react to allow store owner edit their store address with google map place autocomplete.
I have three components:
EditStoreInfoPage is the page component, which contains EditStoreInfoForm.
EditStoreInfoForm is the form component, which contains FormikAddressField. I uses Formik here.
FormikAddressField is the one form field that supports google place autocomplete.
Store information will be fetched from backend in EditStoreInfoPage, and passed down to EditStoreInfoForm and FormikAddressField. Whenever a new address is typed in FormikAddressField, it calls a callback function handleStoreLocationUpdate passed down from EditStoreInfoPage.
Issue
Render the page without any issue. I see that formValues are populated corrected with the data fetched from backend.
However, once I finished typing the address, the form get cleared except the store address is still there.
From the console output of above screenshot, I can see that function handleStoreLocationUpdate get called, however, console.log(formValues); in function handleStoreLocationUpdate of EditStoreInfoPage contains empty value for store fields. I was expecting that the formValues here still kept the value fetched from backend, not sure why these values get wiped out as I use React useState.
Any idea what went wrong?
Code
EditStoreInfoPage
This is the React component that first call backend API to get the store information based on storeIdentifier. formValues will be populated with these information, as you can see that setFormValues is being called. formValues is passed down to child component EditStoreInfoForm as props.
type EditStoreInfoPageProps = {
storeIdentifier: string;
};
const EditStoreInfoPage = (props: EditStoreInfoPageProps) => {
let navigate = useNavigate();
const [formValues, setFormValues] = React.useState<StoreAttributes>({
storeName: "",
storeLocation: "",
storeLocationLongitude: 0,
storeLocationLatitude: 0,
});
// Get store info.
React.useEffect(() => {
const user: CognitoUser | null = getCurrentBusinessAccountUser();
if (!user) {
Toast("Store Not Found!", "Failed to get store information!", "danger");
} else {
const storeIdentifier: string = user?.getUsername();
getStoreInfo(storeIdentifier)
.then((response) => {
setFormValues({
storeName: response?.storeName || "",
storeLocation: response?.storeLocation || "",
storeLocationLatitude: response?.storeLocationLatitude!,
storeLocationLongitude: response?.storeLocationLongitude!,
});
})
.catch((error) =>
Toast(
"Store Not Found!",
"Failed to get store information!",
"danger"
)
);
}
}, []);
const handleStoreLocationUpdate = (newStoreLocation: string) => {
const geocoder = new window.google.maps.Geocoder();
console.log("handleStoreLocationUpdate");
console.log(newStoreLocation);
console.log(formValues);
const geocodeRequest = { address: newStoreLocation };
const geocodeCallback = (
results: google.maps.GeocoderResult[] | null,
status: google.maps.GeocoderStatus
) => {
if (status === "OK") {
if (results && results[0]) {
const formValuesClone: StoreAttributes = structuredClone(formValues);
formValuesClone.storeLocation = newStoreLocation;
formValuesClone.storeLocationLatitude =
results[0].geometry.location.lat();
formValuesClone.storeLocationLongitude =
results[0].geometry.location.lng();
setFormValues(formValuesClone);
} else {
Toast("Not valid address!", "Please input a valid address", "danger");
}
} else {
Toast("Not valid address!", "Please input a valid address", "danger");
}
};
geocoder.geocode(geocodeRequest, geocodeCallback);
};
const handleSubmit = (data: StoreAttributes) => {
updateStore(props.storeIdentifier, JSON.stringify(data, null, 2))
.then((response) => {
if (response.status == 200) {
Toast(
"Updated!",
"The store information has been updated. Redirect to store page...",
"success"
);
navigate("/stores/" + props.storeIdentifier);
} else {
Toast(
"Updated failed!",
"Failed to update store information.",
"danger"
);
}
})
.catch((error) => {
Toast("Updated failed!!", error.message, "danger");
});
};
const handleUpdate = (data: StoreAttributes) => {
// make a deep clone here, as formValues here is an object.
console.log("handleUpdate");
const copy = structuredClone(data);
setFormValues(copy);
};
return (
<EditStoreInfoForm
formValues={formValues}
handleStoreLocationUpdate={handleStoreLocationUpdate}
handleUpdate={handleUpdate}
handleSubmit={handleSubmit}
/>
);
};
export default EditStoreInfoPage;
EditStoreInfoForm
EditStoreInfoForm is the form component. I use Formik here. It renders the form with props.formValues. It contains a child component FormikAddressField which will be used to support google place auto complete.
export type EditStoreInfoFormProps = {
formValues: StoreAttributes;
handleStoreLocationUpdate: any;
handleUpdate: any;
handleSubmit: any;
};
const EditStoreInfoForm = (props: EditStoreInfoFormProps) => {
console.log("EditStoreInfoForm");
const onBlur = () => {
console.log(props.formValues);
}
return (
<div className="flex justify-center items-center">
<Formik.Formik
initialValues={props.formValues}
enableReinitialize={true}
validationSchema={validationSchema}
validateOnChange={false}
validateOnBlur={false}
onSubmit={(values) => {
props.handleSubmit(values);
}}
>
{({ }) => (
<Formik.Form className="w-1/3">
<div className="form-group">
<div>
<FormikTextField
label="Store Name"
name="storeName"
placeholder={props.formValues?.storeName}
/>
</div>
<div className="form-group">
<FormikAddressField
label="Store Location"
name="storeLocation"
onAddressUpdate={props.handleStoreLocationUpdate}
placeholder={props.formValues?.storeLocation}
/>
</div>
<div className="w-full h-60">
{/* <GoogleMapLocationPin latitude={10} longitude={10} text="store"/> */}
<StoresGoogleMapLocation
googleMapCenter={{
lat: props.formValues.storeLocationLatitude,
lng: props.formValues.storeLocationLongitude,
}}
storeAddress={props.formValues?.storeLocation}
storeLocationLongitude={
props.formValues?.storeLocationLongitude
}
storeLocationLatitude={props.formValues?.storeLocationLatitude}
/>
</div>
<div className="form-group">
<button type="submit" className="form-button m-2 w-20 h-10">
Update
</button>
</div>
</Formik.Form>
)}
</Formik.Formik>
</div>
);
};
export default EditStoreInfoForm;
FormikAddressField
FormikAddressField is the field for autocomplete. See https://developers.google.com/maps/documentation/javascript/place-autocomplete to know what it is.
const FormikAddressField = ({ label, onAddressUpdate, ...props }: any) => {
const [field, meta] = useField(props);
const loader = new Loader({
apiKey: process.env.REACT_APP_GOOGLE_MAP_API_KEY!,
libraries: ["places", "geometry"],
});
const locationInputId = "locationInputId";
let searchInput: HTMLInputElement;
const autoCompleteInstanceRef = React.useRef<any>(null);
React.useEffect(() => {
loader.load().then(() => {
let searchInput = document.getElementById(
locationInputId
) as HTMLInputElement;
//console.log(searchInput);
autoCompleteInstanceRef.current = new google.maps.places.Autocomplete(
searchInput!,
{
// restrict your search to a specific type of resultmap
//types: ["address"],
// restrict your search to a specific country, or an array of countries
// componentRestrictions: { country: ['gb', 'us'] },
}
);
autoCompleteInstanceRef.current.addListener(
"place_changed",
onPlaceChanged
);
});
// returned function will be called on component unmount
return () => {
google.maps.event.clearInstanceListeners(searchInput!);
};
}, []);
const onPlaceChanged = () => {
const place: google.maps.places.PlaceResult =
autoCompleteInstanceRef.current.getPlace();
if (!place) return;
onAddressUpdate(place.formatted_address);
};
return (
<>
<label htmlFor={props.id || props.name} className="form-label">
{label}
</label>
<Field
id={locationInputId}
className="text-md w-full h-full m-0 p-0"
type="text"
{...field}
{...props}
/>
{meta.touched && meta.error ? (
<div className="error">{meta.error}</div>
) : null}
</>
);
};
export default FormikAddressField;
CodeSandbox
Here is a simplified version: https://nv1m89.csb.app/
The EditStoreInfoPage is above the EditStoreInfoForm. The formikValues in EditStoreInfoPage appears to be a copy, which is not updated every time the actual real-time formik values in EditStoreInfoForm are changed. Your real problem here is that you shouldn't have the clone in the first place.
Just pass the real store values up to the handler:
<FormikAddressField
label="Store Location"
name="storeLocation"
onAddressUpdate={(newAddress) => props.handleStoreLocationUpdate(newAddress, formValues)}
placeholder={props.formValues?.storeLocation}
/>
Now change:
const handleStoreLocationUpdate = (newStoreLocation: string) => {
To:
const handleStoreLocationUpdate = (newStoreLocation: string, formValues: StoreAttributes) => {
And use that argument.
As mentioned there are other issues here. Really you should refactor to get rid of this completely:
const [formValues, setFormValues] = React.useState<StoreAttributes>({
storeName: "",
storeLocation: "",
storeLocationLongitude: 0,
storeLocationLatitude: 0,
});
You'd do it by making the actual form state accessible to that component. Probably by changing to the useFormik pattern and loading that hook in the parent.
In Comp1 when i hover mouse on it i want the state to change to true (I'm passing true param to it). Also i want to ensure that by doing so this will not cause the Comp2 component to re-render.
My understanding was that if i do like so useStoreOnHover.setState({ onComp1: true }) it should work but it does not :(
I have also tried with const onComp1Set = useStoreOnHover((s) => s.onComp1Set) but still same :(
The only way i was able to get it working is by const { onComp1Set } = useStoreOnHover() but I'm trying to avoid these type of de-structuring because it also triggers re-renders to other components.
Live example: https://codesandbox.io/s/winter-grass-qxrv8
import create, { GetState, SetState } from "zustand";
type typeStoreOnHover = {
onComp1: boolean;
onComp1Set: (val: boolean) => void;
onComp2: boolean;
};
export const useStoreOnHover = create<typeStoreOnHover>(
(set: SetState<typeStoreOnHover>, get: GetState<typeStoreOnHover>) => {
return {
onComp1: false,
onComp1Set: (val) => set({ onComp1: val }),
onComp2: false
};
}
);
const Comp1 = () => {
const onComp1 = useStoreOnHover.getState().onComp1;
// const onComp1Set = useStoreOnHover((s) => s.onComp1Set);
console.log("Comp 1", onComp1);
return (
<div
onMouseEnter={() => {
// onComp1Set(true);
useStoreOnHover.setState({ onComp1: true });
}}
>
Comp 1 {onComp1 ? "True" : "False"}
</div>
);
};
const Comp2 = () => {
const onComp2 = useStoreOnHover((s) => s.onComp2);
console.log("Comp 2", onComp2);
return <div>Comp 2 </div>;
};
export default function App() {
return (
<>
<Comp1 />
<Comp2 />
</>
);
}
According to zustand documentation, this should be the actual approach,
const onComp1 = useStoreOnHover((s) => s.onComp1);
I have tested it on your CodeSandBox link and it worked.
I am not familiar with the zustand library but this might help you.
I have a simple useEffect hook in my Task:
const TaskAD = ({ match }: TaskADProps) => {
const { taskName } = match.params;
const [task, setTask] = useState<TaskData | null>(null);
const [loading, setLoading] = useState(true);
const authCommunicator = authRequest();
useEffect(() => {
const getTask = async () => {
const taskData = await authCommunicator
.get(`/task/${taskName}`)
.then((response) => response.data);
setTask(taskData);
setLoading(false);
};
getTask();
}, []);
if (loading || task == null) {
return <Spinner centered />;
}
const updateDescription = async (content: string): Promise<boolean> => {
const r = await authCommunicator
.patch(`/task/${task.name}/`, {
description: content,
})
.then((response) => {
console.log("Setting Task data!");
setTask(response.data);
return true;
})
.catch(() => false);
return r;
};
return (
<ProjectEntity name={taskName}>
<Space direction="vertical" size="small" style={{ width: "100%" }}>
<StatusRow status="Open" />
<TaskDetails task={task} />
<Description content={task.description} onSubmit={updateDescription} />
<Title level={2}>Subtasks:</Title>
<Table dataSource={dataSource} columns={columns} />
</Space>
</ProjectEntity>
);
};
Task object contains a description. The description is another component with a text area. The idea is: when a user changes the description in the child component, the child component has a function (passed via props) to update the description.
So I pass updateDescription to my child component (Description) via props. Both useEffect and updateDescription are in my Task component, the Description component is basically stateless. What happens:
user updates a description
child component calls the function, it updates a record in my DB
it gets the response from the API and calls setTask
task variable is passed to Description via props in Task's render, so they both get updated since the state of parent Task has changed
I see updated description
The only problem is that although it work, but when I do this, I can see this in console:
Setting Task data!
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
(i've added the console.log just to see when it happens).
So I wanted to ask if this is a problem of me having async calls outside useEffect or maybe something else?
#Edit Description code (I removed all the unnecessary junk):
interface DescriptionProps {
content: string;
onSubmit?: (content: string) => Promise<boolean>;
title?: string;
rows?: number;
}
const Description = (props: DescriptionProps) => {
const { content, onSubmit, title, rows } = props;
const [descriptionContent, setDescriptionContent] = useState(content);
const [expanded, setExpanded] = useState(true);
const [editMode, setEditMode] = useState(false);
const [descriptionChanged, setDescriptionChanged] = useState(false);
const editable = onSubmit !== undefined;
const resetDescription = () => {
setDescriptionContent(content);
setDescriptionChanged(false);
};
const changeDescription = (value: string) => {
setDescriptionContent(value);
setDescriptionChanged(true);
};
const descriptionTitle = (
<>
<S.DescriptionTitle>{title}</S.DescriptionTitle>
</>
);
return (
<Collapse
defaultActiveKey={["desc"]}
expandIcon={S.ExpandIcon}
onChange={() => setExpanded(!expanded)}
>
<S.DescriptionHeader header={descriptionTitle} key="desc">
<S.DescriptionContent
onChange={(event): void => changeDescription(event.target.value)}
/>
{descriptionChanged && onSubmit !== undefined ? (
<S.DescriptionEditActions>
<Space size="middle">
<S.SaveIcon
onClick={async () => {
setDescriptionChanged(!(await onSubmit(descriptionContent)));
}}
/>
<S.CancelIcon onClick={() => resetDescription()} />
</Space>
</S.DescriptionEditActions>
) : null}
</S.DescriptionHeader>
</Collapse>
);
};
#Edit2
Funny thing, adding this to my Description solves the issue:
useEffect(
() => () => {
setDescriptionContent("");
},
[content]
);
Can anyone explain why?
I'm building an app using react, redux, and redux-saga.
The situation is that I'm getting information from an API. In this case, I'm getting the information about a movie, and I will update this information using a basic form.
What I would like to have in my text fields is the value from the object of the movie that I'm calling form the DB.
This is a brief part of my code:
Im using 'name' as an example.
Parent component:
const MovieForm = (props) => {
const {
movie,
} = props;
const [name, setName] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSubmit({
name,
});
};
const handleSetValues = () => {
console.log('hi');
console.log(movie, name);
setName(movie.name);
setValues(true);
};
useEffect(() => {
if (movie && values === false) {
handleSetValues();
}
});
return (
<Container>
<TextField
required
**defaultValue={() => {
console.log(movie, name);
return movie ? movie.name : name;
}}**
label='Movie Title'
onChange={(e) => setName(e.target.value)}
/>
</Container>
);
};
export default MovieForm;
....
child component
const MovieUpdate = (props) => {
const { history } = props;
const { id } = props.match.params;
const dispatch = useDispatch();
const loading = useSelector((state) => _.get(state, 'MovieUpdate.loading'));
const created = useSelector((state) => _.get(state, 'MovieUpdate.created'));
const loadingFetch = useSelector((state) =>
_.get(state, 'MovieById.loading')
);
const movie = useSelector((state) => _.get(state, 'MovieById.results'));
useEffect(() => {
if (loading === false && created === true) {
dispatch({
type: MOVIE_UPDATE_RESET,
});
}
if (loadingFetch === false && movie === null) {
dispatch({
type: MOVIE_GET_BY_ID_STARTED,
payload: id,
});
}
});
const updateMovie = (_movie) => {
const _id = id;
const obj = {
id: _id,
name: _movie.name,
}
console.log(obj);
dispatch({
type: MOVIE_UPDATE_STARTED,
payload: obj,
});
};
return (
<div>
<MovieForm
title='Update a movie'
buttonTitle='update'
movie={movie}
onCancel={() => history.push('/app/movies/list')}
onSubmit={updateMovie}
/>
</div>
);
};
export default MovieUpdate;
Then, the actual problem is that when I use the default prop on the text field the information appears without any problem, but if i use defaultValue it is empty.
Ok, I kind of got the answer, I read somewhere that the defaultValue can't be used int the rendering.
So I cheat in a way, I set the properties multiline and row={1} (according material-ui documentation) and I was able to edit this field an receive a value to display it in the textfield
I am working on a React application and have been using the react-admin framework.
I need to pre-process the data coming from a form given that I need separate tables for a new employee and their address but don't want to split it into two screens.
I found the Using onSave To Alter the Form Submission Behavior section in the react-admin's Create/Edit View documentation and I applied it to my code (sample below) in hopes that it would allow me to process the data before getting into the dataProvider. Unfortunately, I can't seem to get the data out of the form and into the callback for the CreateEntity button module.
Create View
const CreateActions = props => (
<TopToolbar {...props}>
<CreateEntityButton props={props} variant="contained" label={"Create"}/>
</TopToolbar>
);
const EmployeeCreate = props => (
<Create {...props} >
<TabbedForm toolbar={<CreateActions record={props.record} redirect="show" />}>
<FormTab label="Identity">
<span >
<PersonInput />
</span>
</FormTab>
<FormTab label="Address">
<span >
<AddressInput />
</span>
</FormTab>
</TabbedForm>
</Create>
)
export default TequitiEmployeeCreate;
When I step through the logic in the browser, the callback function in the handleSave method (below) passes down undefined for both the values and the redirect parameters.
I expected the values object to contain all the input values from the TabbedForm so that it could be parsed and then passed over to my dataProvider module.
CreateEntityButton logic:
const CreateEntityButton = ({ ...props}) => {
const [create] = useCreate(props.props.resource);
const redirectTo = useRedirect();
const notify = useNotify();
const { basePath } = props;
const handleSave = useCallback(
(values, redirect) => { // <-------- undefined all the time
console.log(values);
console.log(redirect);
create(
{
payload: { data: { ...values } },
},
{
onSuccess: ({ data: newRecord }) => {
notify('ra.notification.created', 'info', {
smart_count: 1,
});
redirectTo(redirect, basePath, newRecord.id, newRecord);
},
}
);
},
[create, notify, redirectTo, basePath]
);
return <SaveButton
label={props.label}
variant={props.variant}
handleSubmitWithRedirect={handleSave}
/>;
};
I thought that perhaps having separate modules for PersonInput and AddressInput was to blame for this, but even consolidating all those components into a single one, didn't help.
Any help/thoughts would be helpful.
Turns out, I was mixing the example and was using handleSubmiutWithRedirect instead of the onSave action in the SaveButton.
const CreateEntityButton = ({ ...props}) => {
const resource = props.props.resource;
const redirectTo = useRedirect();
const notify = useNotify();
const { basePath } = props.props;
const dataProvider = useDataProvider();
const handleSave = useCallback(
(values) => {
const createPerson = new PersonAddressCreate(dataProvider);
createPerson.create(values, resource)
.then((data)=>{
notify('ra.notification.created', 'info', { smart_count: 1 });
redirectTo("show", basePath, data.id, data)
})
.catch((error)=>{
notify(error, 'error', { smart_count: 1 });
})
},
[notify, redirectTo, basePath]
);
return <SaveButton
{...props.props}
label={props.label}
variant={props.variant}
onSave={handleSave}
/>;
};