Abstraction for cleaner CRUD apps in React - javascript

I have a new app I'm working on that has a lot of CRUD functionality. A list/grid and then a modal with multiple tabs of content and a save button in the modal header. I'm wondering about patterns to separate state logic from the JSX component so it's easier to read/maintain.
I read about "lifting state", but that makes this item component unruly with the callbacks (and it'll get worse for sure as more detail is added). Is it possible to put the handlers and the state itself in a custom hook and then pull them in where needed, instead of having it at "the closest common ancestor"? For example,
const {company, setCompany, updateCompany, getCompanyByCode, createCompany, onNameChangeHandler, onTitleChangeHandler, etc.} from "./useCompany"
Would all users of this hook have the same view of the data and be able to see and effect updates? I've read about putting data fetching logic in hooks, but what about change handlers for what could be 20 or more fields? I think it's the clutter of the callbacks that bothers me the most.
Here is a shortened version of a component that renders the tabs and data for a specific domain item. Is this a use case for context and/or custom hooks?
import {
getCompanyByCode,
updateCompany,
createCompany,
companyImageUpload,
} from "../../api/companyApi";
const initialState: CompanyItemDetailModel = {
code: "",
name: { en: "", es: "" },
title: { en: "", es: "" },
description: { en: "", es: "" },
displayOrder: 0,
enabled: false,
};
export interface CompanyItemProps {
onDetailsDialogCloseHandler: DetailsCancelHandler;
companyCode: string;
onSaveHandler: () => void;
}
const CompanyItem = (props: CompanyItemProps) => {
const { CompanyCode, onDetailsDialogCloseHandler, onSaveHandler } = props;
const classes = useStyles();
const [tabValue, setTabValue] = useState<Number>(0);
const [isLoading, setIsLoading] = useState(true);
const nameRef = useRef({ en: "", es: "" });
const [Company, setCompany] = useState<CompanyItemDetailModel>(initialState);
const [saveGraphics, setSaveGraphics] = useState(false);
useEffect(() => {
async function getCompany(code: string) {
try {
const payload = await getCompanyByCode(code);
nameRef.current.en = payload.name.en;
setCompany(payload);
} catch (e) {
console.log(e);
} finally {
setIsLoading(false);
}
}
if (CompanyCode.length > 0) {
getCompany(CompanyCode);
}
}, [CompanyCode]);
function handleTabChange(e: React.ChangeEvent<{}>, newValue: number) {
setTabValue(newValue);
}
function detailsDialogSaveHandler() {
const CompanyToSave = {
...Company,
name: JSON.stringify({ ...Company.name }),
description: JSON.stringify({ ...Company.description }),
title: JSON.stringify({ ...Company.title }),
};
if (CompanyCode.length > 0) {
updateCompany(CompanyToSave as any).then((e) => handleSaveComplete());
} else {
createCompany(CompanyToSave as any).then((e) => handleSaveComplete());
}
setSaveGraphics(true);
}
function handleSaveComplete() {
onSaveHandler();
}
function detailsDialogCloseHandler(e: React.MouseEvent) {
onDetailsDialogCloseHandler(e);
}
function handleNameTextChange(e: React.ChangeEvent<HTMLInputElement>) {
setCompany((prevState) => ({
...prevState,
name: {
...prevState.name,
en: e.target.value,
},
}));
}
function handleCompanyCodeTextChange(e: React.ChangeEvent<HTMLInputElement>) {
setCompany((prevState) => ({
...prevState,
code: e.target.value,
}));
}
function handleEnabledCheckboxChange(e: React.ChangeEvent<HTMLInputElement>) {
setCompany((prevState) => ({
...prevState,
enabled: e.target.checked,
}));
}
function handleTitleTextChange(e: React.ChangeEvent<HTMLInputElement>) {
setCompany((prevState) => ({
...prevState,
title: {
...prevState.title,
en: e.target.value,
},
}));
}
return (
<DetailsDialog>
<div className={classes.root}>
<Form>
<ModalHeader
name={nameRef.current}
languageCode={"en"}
onTabChange={handleTabChange}
onCancelHandler={detailsDialogCloseHandler}
onSaveHandler={detailsDialogSaveHandler}
tabTypes={[
DetailsTabTypes.Details,
DetailsTabTypes.Images,
]}
/>
<TabPanel value={tabValue} index={0} dialog={true}>
<CompanyDetailsTab
details={Company}
isEditMode={CompanyCode.length > 1}
languageCode={"en"}
handleNameTextChange={handleNameTextChange}
handleCompanyCodeTextChange={handleCompanyCodeTextChange}
handleEnabledCheckboxChange={handleEnabledCheckboxChange}
handleTitleTextChange={handleTitleTextChange}
handleDescriptionTextChange={handleDescriptionTextChange}
/>
</TabPanel>
<TabPanel value={tabValue} index={1} dialog={true}>
<ImageList />
</TabPanel>
</Form>
</div>
</DetailsDialog>
);
};
export default CompanyItem;

Related

State getting reinitialized to its original value - useState or useReduce

I created a custom hook (useAuth) to extend the third-party Authentication service Auth0's useAuth0 hook and set some local variables that holds basic user information, such as userId.
I have a master account that can impersonate other accounts. This means that it overrides the userId from my custom hook and it gets propagated throughout the system.
The problem that I'm facing is that whenever I call the impersonate function that changes this hook's inner state, it changes it, but then reinitializes itself. I don't know what is causing this reinitialization. The code is down below.
import { useAuth0 } from '#auth0/auth0-react';
import produce from 'immer';
import { useState, useEffect, useCallback, useReducer, Reducer } from 'react';
import { AccountType, Auth0HookUser, TenantInfo, TenantType } from '../#dts';
type AuthVariants =
| 'INDIVIDUAL_TEACHER'
| 'INSTITUTION_TEACHER'
| 'STUDENT'
| 'SECRETARY'
| 'COORDINATOR'
| 'ADMINISTRATOR';
type AuthTenant = {
accountType: AccountType;
tenantType: TenantType;
employeeId: string;
tenantId: string;
selectedTenant: TenantInfo;
variant: AuthVariants;
mode: 'IMPERSONATION' | 'NORMAL';
user: Auth0HookUser;
};
const defaultAuthTenant: () => AuthTenant = () => ({
accountType: 'teacher',
employeeId: '',
mode: 'NORMAL',
selectedTenant: {
accountType: 'teacher',
tenantType: 'INSTITUTION',
tenantId: '',
},
tenantId: '',
tenantType: 'INSTITUTION',
variant: 'INDIVIDUAL_TEACHER',
user: {
name: '',
nickname: '',
} as any,
});
type Action =
| {
type: 'UPDATE_AUTH';
auth: AuthTenant;
}
| {
type: 'IMPERSONATE';
impersonatedEmployeeId: string;
impersonatedName: string;
accountType: AccountType;
}
| {
type: 'EXIT_IMPERSONATION';
};
type State = {
current: AuthTenant;
original: AuthTenant;
};
const reducer = produce((state: State, action: Action) => {
switch (action.type) {
case 'IMPERSONATE':
console.log('Impersonating');
const selectedTenant =
state.current.user['https://app.schon.io/user_data'].tenants[0];
state.current = {
...state.current,
user: {
...state.current.user,
name: action.impersonatedName,
nickname: action.impersonatedName,
'https://app.schon.io/user_data': {
...state.current.user['https://app.schon.io/user_data'],
userId: action.impersonatedEmployeeId,
},
},
mode: 'IMPERSONATION',
accountType: action.accountType,
employeeId: action.impersonatedEmployeeId,
variant: getVariant(action.accountType, selectedTenant.tenantType),
selectedTenant: {
...state.current.selectedTenant,
accountType: action.accountType,
},
};
return state;
case 'UPDATE_AUTH':
state.current = action.auth;
state.original = action.auth;
return state;
default:
return state;
}
});
export function useAuth() {
const { user: _user, isAuthenticated, isLoading, ...auth } = useAuth0();
const user = _user as Auth0HookUser;
const [selectedTenantIndex, setSelectedTenantIndex] = useState(0);
const [state, dispatch] = useReducer<Reducer<State, Action>>(reducer, {
current: defaultAuthTenant(),
original: defaultAuthTenant(),
});
const impersonate = (
impersonatedEmployeeId: string,
accountType: AccountType,
impersonatedName: string,
) => {
if (!user) {
return;
}
dispatch({
type: 'IMPERSONATE',
accountType,
impersonatedEmployeeId,
impersonatedName,
});
};
const exitImpersonation = useCallback(() => {
dispatch({ type: 'EXIT_IMPERSONATION' });
}, []);
useEffect(() => {
if (isLoading || (!isLoading && !isAuthenticated)) {
return;
}
if (!user || state.current.mode === 'IMPERSONATION') {
return;
}
console.log('Use Effect Running');
const { tenants, userId } = user['https://app.schon.io/user_data'];
const selectedTenant = tenants[selectedTenantIndex];
const { accountType, tenantType } = selectedTenant;
dispatch({
type: 'UPDATE_AUTH',
auth: {
tenantId: selectedTenant.tenantId,
employeeId: userId,
mode: 'NORMAL',
variant: getVariant(accountType, tenantType),
user,
selectedTenant,
accountType,
tenantType,
},
});
}, [
user,
isAuthenticated,
isLoading,
selectedTenantIndex,
state.current.mode,
]);
console.log('State Current', state.current);
return {
isAuthenticated,
isLoading,
impersonate,
exitImpersonation,
setSelectedTenantIndex,
...auth,
...state.current,
};
}
function getVariant(
accountType: AccountType,
tenantType: TenantType,
): AuthVariants {
if (accountType === 'teacher') {
return tenantType === 'INSTITUTION'
? 'INSTITUTION_TEACHER'
: 'INDIVIDUAL_TEACHER';
}
return accountType.toUpperCase() as AuthVariants;
}
See the picture. After I call the impersonate function it sets it to the impersonated mode but re-initializes itself and sets it to the default.
This is what I've tried:
Double Checked that proper dependencies were passed to the useEffect (it is not the one causing the re-initialize).
I was using a useStae before the reducer, and I was calling it via its function vs setting the state directly.
I tried stepping in (debugging) throughout the entire cycle, and didn't find anything.
I went through several SO posts and React dosc to see if I could find any issues, but my blinded eye couldn't see it.
Here's a view where I'm calling it from (See the const {impersonate} = useAuth()) :
import React, { memo, useCallback, useMemo, useState } from 'react';
import { RouteComponentProps } from '#reach/router';
import { Button, Typography } from 'components';
import Skeleton from 'react-loading-skeleton';
import { useAuth } from '../../../../../auth';
import { Tabs, Dialog } from '../../../../../components/';
import { useAllClassesAndTeacherForInstitution } from '../../../../../graphql';
import { useThemeSpacing } from '../../../../../shared-styles/material-ui';
import { AddClassTeacher, ListClassTeacher } from './components';
type TeacherViewRouteProps = {
teacherId: string;
};
export const TeacherView: React.FC<RouteComponentProps<
TeacherViewRouteProps
>> = memo((props) => {
const { impersonate } = useAuth();
const { teacherId } = props;
const { data, loading } = useAllClassesAndTeacherForInstitution(teacherId!);
const [open, setOpen] = useState(false);
const openDialog = useCallback(() => setOpen(true), []);
const closeDialog = useCallback(() => setOpen(false), []);
const spacing = useThemeSpacing(4)();
const teacherName = `${data?.teacher.name.fullName}`;
const impersonateTeacher = useCallback(() => {
if (!teacherName || !teacherId) {
return;
}
impersonate(teacherId!, 'teacher', teacherName);
closeDialog();
// props?.navigate?.('/');
}, [impersonate, closeDialog, teacherId, teacherName]);
const tabOptions = useMemo(
() => [
{
label: `Clases de ${teacherName}`,
},
{
label: 'Agregar Clases',
},
],
[teacherName],
);
return (
<>
<Typography variant="h1" className={spacing.marginTopBottom}>
{(loading && <Skeleton />) || teacherName}
</Typography>
<Dialog
title={`Entrar en la cuenta de ${teacherName}`}
open={open}
onAgree={impersonateTeacher}
onClose={closeDialog}
>
¿Desea visualizar la cuenta de {teacherName}?
<br />
Si desea salir de la misma por favor refresque la página.
</Dialog>
<Button className={spacing.marginTopBottom} onClick={openDialog}>
Entrar en cuenta de {teacherName || 'maestro'}
</Button>
{process.env.NODE_ENV === 'development' && (
<>
<Tabs options={tabOptions}>
<>
{data?.teacher.klasses && (
<ListClassTeacher
klasses={data.teacher.klasses}
teacherName={teacherName || 'maestro'}
/>
)}
</>
<>
{data?.grades && (
<AddClassTeacher
existingClasses={data?.teacher.klasses || []}
grades={data.grades}
teacherId={teacherId!}
/>
)}
</>
</Tabs>
</>
)}
</>
);
});
export default TeacherView;
Here's the initial Provider:
import React, { Suspense, memo } from 'react';
import { Location } from '#reach/router';
import { ThemeProvider } from '#material-ui/core';
import { ApolloProvider } from '#apollo/client';
import { theme } from 'components';
import { Auth0Provider } from '#auth0/auth0-react';
import CircularLoader from './components/CircularProgress';
import { useGlobalClient } from './utilities/client';
import { Layout } from './views/Layout';
import { Root } from './views/Root';
import { enableIfNotPreRendering } from './utilities/isPrerendering';
import { AUTH_CONFIG } from './auth/auth0.variables';
console.log('AUTH CONFIG', AUTH_CONFIG);
function App() {
// This will be a method to enable faster loading times.
/**\
* Main AppMethod which hosts the site. To improve FCP it was split into
* 2 files: The main file which will load the <Home component without any
* dependencies (making it extremely fast to load at the beginning as it won't)
* download all the code on its entirety.
*
* All consumers that are descendants of a Provider will re-render whenever the Provider’s value prop changes.
* The propagation from Provider to its descendant consumers is not subject to the
* shouldComponentUpdate method, so the consumer is updated even when an ancestor component
* bails out of the update.
*
* Check this out whenever you're planning on implementing offline capabilities:
* https://dev.to/willsamu/how-to-get-aws-appsync-running-with-offline-support-and-react-hooks-678
*/
return (
<Suspense fallback={<CircularLoader scrollsToTop={true} />}>
<Location>
{({ location }) => (
<Auth0Provider
{...AUTH_CONFIG}
location={{ pathname: location.pathname, hash: location.hash }}
>
<ProviderForClient />
</Auth0Provider>
)}
</Location>
</Suspense>
);
}
/**
* This is done like this because we are using the useAuth0 Hook
* and we need it to be after the Auth0Provider!!
* #param props
*/
export const ProviderForClient: React.FC = (props) => {
const globalClient = useGlobalClient();
return (
<ThemeProvider theme={theme}>
<ApolloProvider client={globalClient.current as any}>
<Layout>
<>{enableIfNotPreRendering() && <Root />}</>
</Layout>
</ApolloProvider>
</ThemeProvider>
);
};
export default memo(App);
I feel retarded. The hook was functioning normally. There's nothing wrong with the approach above (some other things can be debated). The problem was that I was not passing the hook via a context, but I was just calling the hook on each of the components. This meant that the hook was recreating the state (as it should be) per component, so whenever I updated the state, it would only be stated in one component.
That was it. I just had to rewrite the hook as a component and share it with a context.
Here's the final code (omitted some typings due to brevity)
const AuthContext = createContext<Context>({
isAuthenticated: false,
isLoading: false,
getAccessTokenSilently() {
return '' as any;
},
getAccessTokenWithPopup() {
return '' as any;
},
getIdTokenClaims() {
return '' as any;
},
loginWithPopup() {
return '' as any;
},
loginWithRedirect() {
return '' as any;
},
logout() {
return '' as any;
},
impersonate(a: string, b: AccountType, c: string) {},
exitImpersonation() {},
setSelectedTenantIndex(i: number) {},
accountType: 'teacher',
employeeId: '',
mode: 'NORMAL',
selectedTenant: {
accountType: 'teacher',
tenantType: 'INSTITUTION',
tenantId: '',
},
tenantId: '',
tenantType: 'INSTITUTION',
user: {
name: '',
nickname: '',
} as any,
variant: 'INSTITUTION_TEACHER',
});
/**
* A custom wrapper for Auth. This allows us to set impersonation
*/
export const Auth: React.FC = memo((props) => {
const { user: _user, isAuthenticated, isLoading, ...auth } = useAuth0();
const user = _user as Auth0HookUser;
const defaultUser = useCallback(
(user?: Auth0HookUser) => getDefaultAuthTenant(user),
[],
);
const [selectedTenantIndex, setSelectedTenantIndex] = useState(0);
const [state, dispatch] = useReducer<Reducer<State, Action>>(reducer, {
current: defaultUser(_user),
original: defaultUser(_user),
});
const calledDispatch = useCallback(dispatch, [dispatch]);
const impersonate = useCallback(
(
impersonatedEmployeeId: string,
accountType: AccountType,
impersonatedName: string,
) => {
if (!user) {
return;
}
calledDispatch({
type: 'IMPERSONATE',
accountType,
impersonatedEmployeeId,
impersonatedName,
});
},
[calledDispatch, user],
);
const exitImpersonation = useCallback(() => {
dispatch({ type: 'EXIT_IMPERSONATION' });
}, []);
useEffect(() => {
if (isLoading || (!isLoading && !isAuthenticated)) {
return;
}
if (!user || state.current.mode === 'IMPERSONATION') {
return;
}
const { tenants, userId } = user['https://app.schon.io/user_data'];
const selectedTenant = tenants[selectedTenantIndex];
const { accountType, tenantType } = selectedTenant;
dispatch({
type: 'UPDATE_AUTH',
auth: {
tenantId: selectedTenant.tenantId,
employeeId: userId,
mode: 'NORMAL',
variant: getVariant(accountType, tenantType),
user,
selectedTenant,
accountType,
tenantType,
},
});
// eslint-disable-next-line
}, [
user,
isAuthenticated,
isLoading,
selectedTenantIndex,
state.current.mode,
]);
return (
<AuthContext.Provider
value={{
isAuthenticated,
isLoading,
impersonate,
exitImpersonation,
setSelectedTenantIndex,
...auth,
...state.current,
}}
>
{props.children}
</AuthContext.Provider>
);
});
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error(
'You need to place the AuthContext below the Auth0Context and on top of the app',
);
}
return context;
}
Now, I just placed the <Auth/> component on the top of my app.

Can't access prevState with useState hook

I have a component where I'm attempting to capture the previousState of something but no matter what it keeps returning the initial value. This is leading me to believe that there's re-rendering that's happening so it keeps defaulting to the initial state.
import React, { useState } from "react";
import { InfoWindow } from "../info-window/info-window";
import { LocationProps } from "../../../interfaces/location-inteface";
export interface Props {
lat: number;
lng: number;
location: LocationProps;
}
type PreviousLocation = {
isActive: boolean;
location: Props["location"];
};
export const InStoreMarker: React.FC<Props> = (props: Props) => {
const { location } = props;
const [isActive, setIsActive] = useState(false);
const [infoWindowVisible, setActiveInfoWindowVisible] = useState(false);
const [activeLocation, setActiveLocation] = useState({
isActive: false,
location: null
});
const [previousLocation, setPreviousLocation] = useState(null);
const onClick = (location: LocationProps, prevLocation: PreviousLocation) => {
setIsActive(true);
setActiveInfoWindowVisible(true);
setPreviousLocation(prevLocation);
setActiveLocation({
isActive: true,
location: location,
});
console.log("Click previousLocation", previousLocation);
console.log("Click location", location);
};
const onClose = (value: boolean) => {
setIsActive(value);
setActiveInfoWindowVisible(value);
};
useEffect(() => {
console.log("activeLocation", activeLocation);
console.log("previousLocation", previousLocation);
}, [previousLocation, activeLocation]);
return (
<div className={styles.inStoreMarkerContainer}>
{isActive && infoWindowVisible && (
<InfoWindow
location={location}
onClose={onClose}
/>
)}
<div
className={styles.inStoreMarker}
onClick={() => onClick(location, activeLocation)}
/>
</div>
);
}
The console log inside of the setActiveLocation callback keeps returning
{
id: null,
isActive: false,
location: null
}
I tried creating a usePrevious function as shown at https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state but that would always return undefined so I'm a little stuck right now.
Maybe a custom hook can help you?
Maybe usePrevious could solve this without adding boilerplate to you actual component
Maybe its would be helpful.
const onClick = useCallback(({ location: { id, isActive, location } }) => {
setActiveLocation((prevState: PreviousLocation) => {
console.log("previousState", prevState);
return new Map(prevState).set(id, isActive, location);
});
}, []);

Updating the state of object values in React

I have an array set in state like:
const Theme = {
name: "theme",
roots: {
theme: Theme,
},
state: {
theme: {
quiz: {
quizGender: null,
quizSleepComfort: {
justMe: {
soft: null,
medium: null,
firm: null,
},
partner: {
soft: null,
medium: null,
firm: null,
}
},
},
},
},
actions: {
// ...
},
};
I then have a component that has checkboxes, one for soft, medium, and firm. The code for the component is:
const Question = ({ state }) => {
const [checkedItems, setCheckedItems] = useState([]);
const checkboxes = [
{
label: "Firm",
value: "firm",
},
{
label: "Medium",
value: "medium",
},
{
label: "Soft",
value: "soft",
},
];
state.theme.quiz.quizSleepComfort.justMe = checkedItems;
return (
<QuestionCommonContainer>
{checkboxes.map((item, id) => (
<QuizCheckbox
label={item.label}
name={item.label}
value={item.value}
selected={checkedItems[item.value] === true}
onChange={(e) => {
setCheckedItems({
...checkedItems,
[e.target.value]: e.target.checked,
});
}}
/>
))}
</QuestionCommonContainer>
);
};
export default connect(Question);
This specific component is just interacting with state.theme.quiz.quizSleepComfort.justMe object, not the partner object.
As of right now when a checkbox is selected, let's say the checkbox for "firm" is checked, the state gets updated to what looks like this:
...
quizSleepComfort: {
justMe: {
firm: true,
},
partner: {
soft: null,
medium: null,
firm: null,
}
},
...
I am trying to figure out how I would be able to alter this components code so that instead of setting the justMe object to include only the items that are checked (in this case "firm"), it should keep the other items as well ("soft", "medium") as null.
Please let me know if there is more info i should provide.
Okay. So the following is bad practice
state.theme.quiz.quizSleepComfort.justMe = checkedItems;
You should pass a function to the Question component, something like onChange.
The onChange function should update the state in your parent component. Use the spread operator ... to get a copy of the old object. for example
const onChange = (newState) =>
setState((oldState) => ({
...oldState,
justMe: { ...oldState.justMe, ...newState },
}));
the resulting object will contain all the properties of the original state but will overwrite any property set on newState in justMe. If the property that you want to update is more nested, just repeat the steps of spreading.
--- UPDATE ---
I have added an example that I think is close to what you are trying to achieve.
const Parent = () => {
const [state, setState] = useState(initialState);
const onChange = useCallback(
(newState) =>
setState((oldState) => ({
...oldState,
theme: {
...oldState.theme,
quiz: {
...oldState.theme.quiz,
quizSleepComfort: {
...oldState.theme.quizSleepComfort,
justMe: {
...oldState.theme.quizSleepComfort.justMe,
...newState,
},,
},
},
},
})),
[],
);
return <Question onChange={onChange} />;
};
const checkboxes = [
{
label: 'Firm',
value: 'firm',
},
{
label: 'Medium',
value: 'medium',
},
{
label: 'Soft',
value: 'soft',
},
];
const Question = ({ onChange }) => {
const [checkedItems, setCheckedItems] = useState([]);
useEffect(() => {
onChange(checkedItems);
}, [checkedItems, onChange]);
return (
<QuestionCommonContainer>
{checkboxes.map((item, id) => (
<QuizCheckbox
label={item.label}
name={item.label}
value={item.value}
selected={checkedItems[item.value] === true}
onChange={(e) => {
setCheckedItems((oldCheckedItems) => ({
...oldCheckedItems,
[e.target.value]: e.target.checked,
}));
}}
/>
))}
</QuestionCommonContainer>
);
};
export default connect(Question);
As you are having a really nested object to update, it might be worth taking a look at Object.assign

Passing state in function inside React functional component

I am still pretty new to react and typescript in general, so there might be some other issues I am not seeing. Most of the tutorials I am finding are for class based components instead of functional ones, making it more difficult.
I have a component that contains two checkboxes. When toggling the checkbox, I would also like to post this update to a url. Currently, the toggles are working and I am able to update the state accordingly. The issue is when attempting to post the update, the updated state is not set in the request, but rather the previous state.
Below is the main Document component. I think the issue is with the updateDocument function, since the state has not necessarily been set by setToggles when it is called. From what I have read, I need to use a callback, but I am unsure how I would implement this.
const Document: FC<{ document: IDocument }> = ({document}): ReactElement => {
const [toggles, setToggles] = useState<DocumentToggles>(_documentToggles)
const updateDocument = (uri: string, id: string, desc: string, checked: boolean, expired: boolean) => {
axios.post(uri, {
id: id,
description: desc,
checked: checked,
expired: expired
}).then(response => {
console.log(response.data)
});
}
const handleToggle = (e: FormEvent<HTMLInputElement>, data: any) => {
console.log(data)
if (e.currentTarget !== null) {
const {name, checked} = data;
setToggles(prevState => ({...prevState, [name]: checked}))
// THIS IS NOT WORKING
updateDocument('http://example.com/update', document.id, document.description, toggles.checked, toggles.expired)
}
}
const handleSubmit = (e: FormEvent) => {
if (e.currentTarget !== null) {
e.preventDefault()
updateDocument('http://example.com/update', document.id, document.description, toggles.checked, toggles.expired)
}
}
return (
<Container>
<Form onSubmit={handleSubmit}>
<DocumentCheckboxes
checked={toggles.checked}
expired={toggles.expired}
handleToggle={handleToggle}
/>
<Form.Field>
<Button fluid type="submit">
Save
</Button>
</Form.Field>
</Form>
</Container>
);
};
I just want to be able to pass up-to date values from the "state" provided by useState to a function within a functional component.
I will also add the whole file for the sake of completeness. But basically it is just a wrapper component around an array of Documents:
const _DOCS: IDocument[] = [{id: "666666666666", description: "TESTDESC", checked: false, expired: false}]
const MainAppView = () => {
return (
<div>
<DocumentViewBox documents={_DOCS}/>
</div>
);
}
interface IDocument {
id: string;
description: string;
checked: boolean;
expired: boolean;
}
// DocumentViewBox is used to display a list of documents.
const DocumentViewBox: FC<{ documents: IDocument[] }> = ({documents}): ReactElement => {
return (
<div>
{documents.map(doc => {
return <Document key={doc.id} document={doc}/>
})}
</div>
);
};
interface DocumentToggles {
checked: boolean;
expired: boolean;
}
const _documentToggles: DocumentToggles = {checked: false, expired: false}
const Document: FC<{ document: IDocument }> = ({document}): ReactElement => {
const [toggles, setToggles] = useState<DocumentToggles>(_documentToggles)
const updateDocument = (uri: string, id: string, desc: string, checked: boolean, expired: boolean) => {
axios.post(uri, {
id: id,
description: desc,
checked: checked,
expired: expired
}).then(response => {
console.log(response.data)
});
}
const handleToggle = (e: FormEvent<HTMLInputElement>, data: any) => {
console.log(data)
if (e.currentTarget !== null) {
const {name, checked} = data;
setToggles(prevState => ({...prevState, [name]: checked}))
// THIS IS NOT WORKING
updateDocument('http://example.com/update', document.id, document.description, toggles.checked, toggles.expired)
}
}
const handleSubmit = (e: FormEvent) => {
if (e.currentTarget !== null) {
e.preventDefault()
updateDocument('http://example.com/update', document.id, document.description, toggles.checked, toggles.expired)
}
}
return (
<Container>
<Form onSubmit={handleSubmit}>
<DocumentCheckboxes
checked={toggles.checked}
expired={toggles.expired}
handleToggle={handleToggle}
/>
<Form.Field>
<Button fluid type="submit">
Save
</Button>
</Form.Field>
</Form>
</Container>
);
};
const DocumentCheckboxes: FC<{ checked: boolean, expired: boolean, handleToggle: (e: FormEvent<HTMLInputElement>, data: any) => void }> = ({checked, expired, handleToggle}): ReactElement => {
return (
<Container textAlign="left">
<Divider hidden fitted/>
<Checkbox
toggle
label="Checked"
name="checked"
onChange={handleToggle}
checked={checked}
/>
<Divider hidden/>
<Checkbox
toggle
label="Expired"
name="expired"
onChange={handleToggle}
checked={expired}
/>
<Divider hidden fitted/>
</Container>
);
}
UPDATE:
Updated Document component with the change provided by #Ibz. The only issue now is that the POST request to the update url is run twice if multiple toggles are toggled. Toggling only a single component will not do this.
const Document: FC<{ document: IDocument }> = ({document}): ReactElement => {
const [toggles, setToggles] = useState<DocumentToggles>(_documentToggles)
const updateDocument = (uri: string, id: string, desc: string, checked: boolean, expired: boolean) => {
axios.post(uri, {
id: id,
description: desc,
checked: checked,
expired: expired
}).then(response => {
console.log(response.data)
});
}
const handleToggle = (e: FormEvent<HTMLInputElement>, data: any) => {
console.log(data)
if (e.currentTarget !== null) {
e.preventDefault()
setToggles(prevState => {
const newState = {...prevState, [data.name]: data.checked};
updateDocument('http://example.com/update', document.id, document.description, newState.checked, newState.expired);
return newState;
})
}
}
const handleSubmit = (e: FormEvent) => {
if (e.currentTarget !== null) {
e.preventDefault()
updateDocument('http://example.com/update', document.id, document.description, toggles.checked, toggles.expired)
}
}
return (
<Container>
<Form onSubmit={handleSubmit}>
<DocumentCheckboxes
checked={toggles.checked}
expired={toggles.expired}
handleToggle={handleToggle}
/>
<Form.Field>
<Button fluid type="submit">
Save
</Button>
</Form.Field>
</Form>
</Container>
);
};
UPDATE 2:
Below is the final working code, slightly simplified from the OP. Thanks to #Ibz for all the help!
Regarding the duplicate POST requests: I was using yarn start to run a development server when I was seeing this issue. After building with yarn build and serving the files with the actual server, the issue is no longer present. This answer on the axios issues page made me try this.
import React, {Component, useState, useEffect, FC, ReactElement, MouseEvent, FormEvent, ChangeEvent} from "react";
import {Container, Segment, Label, Checkbox, CheckboxProps} from "semantic-ui-react";
import axios from "axios";
interface IDocument {
id: string;
description: string;
checked: boolean;
expired: boolean;
}
const _DOCS: IDocument[] = [{id: '0', description: '', checked: false, expired: false}]
const MainAppView = () => {
return (
<div>
<DocumentViewBox documents={_DOCS}/>
</div>
);
}
const DocumentViewBox: FC<{ documents: IDocument[] }> = ({documents}): ReactElement => {
return (
<div>
{documents.map(doc => <Document key={doc.id} document={doc}/>)}
</div>
);
};
const defaultDocumentProps: IDocument = {id: '', description: '', checked: false, expired: false};
const Document: FC<{ document: IDocument }> = ({document}): ReactElement => {
const [documentProps, setDocumentProps] = useState<IDocument>(defaultDocumentProps);
// Run only once and set data from doc
// as the initial state.
useEffect(() => {
setDocumentProps(document)
}, []);
const updateDocument = (uri: string, updateDoc: IDocument) => {
axios.post(uri, updateDoc).then(response => {
console.log('updateDocument response:')
console.log(response.data)
}).catch(err => {
console.log('updateDocument error:' + err)
});
}
const handleToggle = (e: FormEvent<HTMLInputElement>, data: CheckboxProps) => {
e.preventDefault()
setDocumentProps(prevState => {
const {name, checked} = data;
const newState = {...prevState, [name as string]: checked};
console.log('handleToggle new state:')
console.log(newState)
updateDocument('http://example.com/update', newState);
return newState;
});
}
return (
<Checkbox
toggle
label='Checked'
name='checked'
onChange={handleToggle}
checked={documentProps.checked}
/>
);
};
With useState, there's no guarantee that the action is executed in order, as your component needs to re-render to have all the up to date state.
setToggles(prevState => ({...prevState, [name]: checked}))
// THIS IS NOT WORKING
updateDocument('http://example.com/update', document.id, document.description, toggles.checked, toggles.expired)
That means with this piece of code, your component renders, and you have some value in toggles. When you get to setToggles(...), react queues the update of the state for the next render, so when you get to updateDocument, it's being run with the previous value of toggles.
To get around this, we would usually use useEffect. This is a hook which runs some code whenever some other value changes. In your instance, you would want something like:
useEffect(() => {
updateDocument('http://example.com/update', document.id, document.description, toggles.checked, toggles.expired)
}, [document.id, document.description, toggles.checked, toggles.expired])
The second argument to useEffect is called the Dependency Array, and is a list of values that when changed, causes the function inside useEffect to run.
It can be a little tricky wraping your head around state at first, but I hope this helped. Any other questions, just leave a comment. You can find more information here: https://reactjs.org/docs/hooks-effect.html

Material Table not updating table data after mutation

When a user adds additional information, a mutation is made to the database adding the new info, then the local state is updated, adding the new information to the lead.
My mutation and state seem to get updated fine, the issue seems to be that the state of the Material Table component does not match its 'data' prop. I can see in the React Dev tools that the state was updated in the parent component and is being passes down, the table just seems to be using stale data until I manually refresh the page.
I will attach images of the React Devtools as well as some code snippets. Any help would be much appreciated.
Devtools Material Table data prop:
Devtools Material Table State
Material Table Parent Component:
const Leads = () => {
const [leadState, setLeadState] = useState({});
const [userLeadsLoaded, setUserLeadsLoaded] = React.useState(false);
const [userLeads, setUserLeads] = React.useState([]);
const { isAuthenticated, user, loading } = useAuth()
const [
createLead,
{ data,
// loading: mutationLoading,
error: mutationError },
] = useMutation(GQL_MUTATION_CREATE_LEAD);
const params = { id: isAuthenticated ? user.id : null };
const {
loading: apolloLoading,
error: apolloError,
data: apolloData,
} = useQuery(GQL_QUERY_ALL_LEADS, {
variables: params,
});
useEffect(() => {
if (apolloData) {
if (!userLeadsLoaded) {
const { leads } = apolloData;
const editable = leads.map(o => ({ ...o }));
setUserLeads(editable);
setUserLeadsLoaded(true);
};
}
}, [apolloData])
if (apolloLoading) {
return (
<>
<CircularProgress variant="indeterminate" />
</>
);
};
if (apolloError) {
console.log(apolloError)
//TODO: Do something with the error, ie default user?
return (
<div>
<div>Oh no, there was a problem. Try refreshing the app.</div>
<pre>{apolloError.message}</pre>
</div>
);
};
return (
<>
<Layout leadState={leadState} setLeads={setUserLeads} leads={userLeads} setLeadState={setLeadState} createLead={createLead}>
{apolloLoading ? (<CircularProgress variant="indeterminate" />) : (<LeadsTable leads={userLeads} setLeads={setUserLeads} />)}
</Layout>
</>
)
}
export default Leads
Handle Submit function for adding additional information:
const handleSubmit = async (event) => {
event.preventDefault();
const updatedLead = {
id: leadState.id,
first_name: leadState.firstName,
last_name: leadState.lastName,
email_one: leadState.email,
address_one: leadState.addressOne,
address_two: leadState.addressTwo,
city: leadState.city,
state_abbr: leadState.state,
zip: leadState.zipCode,
phone_cell: leadState.phone,
suffix: suffix,
address_verified: true
}
const { data } = await updateLead({
variables: updatedLead,
refetchQueries: [{ query: GQL_QUERY_GET_USERS_LEADS, variables: { id: user.id } }]
})
const newLeads = updateIndexById(leads, data.updateLead)
console.log('New leads before setLeads: ', newLeads)
setLeads(newLeads)
// setSelectedRow(data.updateLead)
handleClose()
};
Material Table Component:
const columnDetails = [
{ title: 'First Name', field: 'first_name' },
{ title: 'Last Name', field: 'last_name' },
{ title: 'Phone Cell', field: 'phone_cell' },
{ title: 'Email', field: 'email_one' },
{ title: 'Stage', field: 'stage', lookup: { New: 'New', Working: 'Working', Converted: 'Converted' } },
{ title: 'Active', field: 'active', lookup: { Active: 'Active' } },
];
const LeadsTable = ({ leads, setLeads }) => {
const classes = useStyles();
const { user } = useAuth();
const [isLeadDrawerOpen, setIsLeadDrawerOpen] = React.useState(false);
const [selectedRow, setSelectedRow] = React.useState({});
const columns = React.useMemo(() => columnDetails);
const handleClose = () => {
setIsLeadDrawerOpen(!isLeadDrawerOpen);
}
console.log('All leads from leads table render: ', leads)
return (
<>
<MaterialTable
title='Leads'
columns={columns}
data={leads}
icons={tableIcons}
options={{
exportButton: false,
hover: true,
pageSize: 10,
pageSizeOptions: [10, 20, 30, 50, 100],
}}
onRowClick={(event, row) => {
console.log('Selected Row:', row)
setSelectedRow(row);
setIsLeadDrawerOpen(true);
}}
style={{
padding: 20,
}}
/>
<Drawer
variant="temporary"
open={isLeadDrawerOpen}
anchor="right"
onClose={handleClose}
className={classes.drawer}
>
<LeadDrawer onCancel={handleClose} lead={selectedRow} setLeads={setLeads} setSelectedRow={setSelectedRow} leads={leads} />
</Drawer>
</>
);
};
export default LeadsTable;
Try creating an object that contains refetchQueries and awaitRefetchQueries: true. Pass that object to useMutation hook as a 2nd parameter. See example below:
const [
createLead,
{ data,
loading: mutationLoading,
error: mutationError },
] = useMutation(GQL_MUTATION_CREATE_LEAD, {
refetchQueries: [{ query: GQL_QUERY_GET_USERS_LEADS, variables: { id: user.id } }],
awaitRefetchQueries: true,
});
Manually updating cache. Example blow is adding a new todo. In your case you can find and update the record before writing the query.
const updateCache = (cache, {data}) => {
// Fetch the todos from the cache
const existingTodos = cache.readQuery({
query: GET_MY_TODOS
});
// Add the new todo to the cache (or find and update an existing record here)
const newTodo = data.insert_todos.returning[0];
cache.writeQuery({
query: GET_MY_TODOS,
data: {todos: [newTodo, ...existingTodos.todos]}
});
};
const [addTodo] = useMutation(ADD_TODO, {update: updateCache});

Categories