4

Context:

The database structure of my application starts at the Company level, a user may be part of more than one company, and he may switch Companies at any point, every other Resource resides inside Company

Every time the user switches company, the whole application changes, because the data changes, it's like a Global Filter, by companyId, a resource that needs to be loaded first and all other depend on it.

So for example, to get the Projects of a Company, the endpoint is /{companyId}/projects

The way I've tried to do this is using a React Context in the Layout, because I need it to encompass the Sidebar as well.

It's not working too well because it is querying userCompanies 4 times on startup, so I'm looking either for a fix or a more elegant solution

Another challenge is also to relay the current companyId to all child Resources, for now I'm using the filter parameter, but I don't know how I will do it in Show/Create/Edit

companyContext.js

const CompanyContext = createContext()

const companyReducer = (state, update) => {
  return {...state, ...update}
}

const CompanyContextProvider = (props) => {
  const dataProvider = useDataProvider();
  const [state, dispatch] = useReducer(companyReducer, {
    loading : true,
    companies : [],
    firstLoad : false
  })

  useEffect(() => {
    if(!state.firstLoad) { //If I remove this it goes on a infinite loop
      //This is being called 3 times on startup
      console.log('querying user companies')
      dataProvider.getList('userCompanies')
        .then(({data}) =>{
          dispatch({
            companies: data,
            selected: data[0].id, //Selecting first as default
            loading: false,
            firstLoad: true
          })
        })
        .catch(error => {
          dispatch({
            error: error,
            loading: false,
            firstLoad: true
          })
        })
    }
  })

  return (
    <CompanyContext.Provider value={[state, dispatch]}>
      {props.children}
    </CompanyContext.Provider>
  )
}

const useCompanyContext = () => {
  const context = useContext(CompanyContext);
  return context
}

layout.js

const CompanySelect = ({companies, loading, selected, callback}) => {
  const changeCompany = (companyId) => callback(companyId)

  if (loading) return <div>Loading...</div>
  if (!companies || companies.length < 1) return <div>You are not part of a company</div>

  return (
    <select value={selected} onChange={(evt) => changeCompany(evt.target.value)}>
      {companies.map(company => <option value={company.id} key={company.id}>{company.name}</option>)}
    </select>
  )
}

const CompanySidebar = (props) => {
  const [companyContext, dispatch] = useCompanyContext();
  const {companies, selected, loading, error} = companyContext;

  const changeCompany = (companyId) => {
    dispatch({
      selected : companyId
    })
  }

  return (
    <div>
      <CompanySelect companies={companies} selected={selected} loading={loading} callback={changeCompany}/>
      <Sidebar {...props}>
        {props.children}
      </Sidebar>
    </div>
  )  
}

export const MyLayout = (props) => {
  return (
    <CompanyContextProvider>
      <Layout {...props} sidebar={CompanySidebar}/>
    </CompanyContextProvider>
  )
};

app.js

const ProjectList = (props) => {
  const [companyContext, dispatch] = useCompanyContext();
  const {selected, loading} = companyContext;
  if(loading) return <Loading />;
  //The filter is how I'm passing the companyId
  return (
    <List {...props} filter={{companyId: selected}}>
      <Datagrid rowClick="show">
        <TextField sortable={false} source="name" />
        <DateField sortable={false} source="createdAt" />
      </Datagrid>
    </List>
  );
};

const Admin = () => {
  return (
    <Admin
      authProvider={authProvider}
      dataProvider={dataProvider}
      layout={MyLayout}
    >
      <Resource name="projects" list={ProjectList}/>
    </Admin>
  );
};
Mojimi
  • 2,561
  • 9
  • 52
  • 116
  • To reduce the number of updates, you need to add a dependency list to the useEffect call: useEffect(() => { ... }, [dataProvider, state, dispatch]). If the list of companies does not change you can do this: useeffect (() => {...}, []). The effect will be called once only when the component is first mounted аnd checking: if (! state.firstload) will not need. – MaxAlex Feb 03 '22 at 02:47
  • You need to add state as a dependency. However, you don't need to add dataProvider since it looks like it is imported and dispatch since : `React guarantees that dispatch function identity is stable and won’t change on re-renders.` [React Docs](https://reactjs.org/docs/hooks-reference.html#usereducer) – SamiElk Feb 03 '22 at 09:34

1 Answers1

2

useEffect querying the api multiple times:

Your useEffect is querying the api 4 times on startup because you haven't set the dependencies array of your useEffect:

If you want to run an effect and clean it up only once (on mount and unmount), you can pass an empty array ([]) as a second argument. React Docs

How to use the context api:

In your example you are dispatching to the context reducer from outside of the context.

However, usually, you want to keep all the context logic inside the context extracting only the functions you need to modify the context. That way you can define the functions only once and have less code duplication.

For example the context will expose the changeCompany function.

useReducer or useState:

In your example you are using a reducer to manage your state.

useReducer is usually preferable to useState when you have complex state logic that involves multiple sub-values or when the next state depends on the previous one. React Docs

Your company, loading and error state all depend on the response from the api. However your states don't depend on each other or on the last state.

In this case you can use multiple states instead of a reducer.

Relaying current companyId to child ressources:

Instead of using a filter in your components, you can directly filter the data you will need in your context. That way you will only have to filter the data once which will optimize performance.

In my example the context exposes selectedCompanyData as well as the selected company id.

That way, you can directly use selectedCompanyData instead of going through the array to find the data each time.

Context solution:

const CompanyContext = React.createContext({
    companies: [],
    selected: null,
    loading: true,
    changeCompany: (companyId) => {},
    updateCompanyData: () => {},
});

const CompanyContextProvider = (props) => {
    const { children } = props;
    const [selectedCompanyId, setSelectedCompany] = useState(null);
    const [companies, setCompanies] = useState([]);
    const [isLoading, setIsLoading] = useState(true);
    const [error, setError] = useState(null);

    /* We want useEffect to run only on componentDidMount so 
    we add an empty dependency array */
    useEffect(() => {
        dataProvider
            .getList('userCompanies')
            .then(({ data }) => {
                setCompanies(data);
                setSelectedCompany(data[0].id);
                setIsLoading(false);
                setError(null);
            })
            .catch((error) => {
                setIsLoading(false);
                setError(error);
            });
    }, []);

    /* You will need to check the guards since I don't know 
    if you want the id as a number or a string */
    const changeCompany = (companyId) => {
        if (typeof companyId !== 'number') return;
        if (companyId <= 0) return;
        setSelectedCompany(companyId);
    };

    const updateCompanyData = () => {
        setIsLoading(true);
        dataProvider
            .getList('userCompanies')
            .then(({ data }) => {
                setCompanies(data);
                setIsLoading(false);
                setError(null);
            })
            .catch((error) => {
                setIsLoading(false);
                setError(error);
            });
    };

    /* You will need to check this since I don't know if your api 
    responds with the id as a number or a string */
    const selectedCompanyData = companies.find(
        (company) => company.id === selectedCompanyId
    );

    const companyContext = {
        companies,
        selected: selectedCompanyId,
        loading: isLoading,
        error,
        selectedCompanyData,
        changeCompany,
        updateCompanyData,
    };

    return (
        <CompanyContext.Provider value={companyContext}>
            {children}
        </CompanyContext.Provider>
    );
};
const useCompanyContext = () => {
    return useContext(CompanyContext);
};

export default CompanyContext;
export { CompanyContext, CompanyContextProvider, useCompanyContext };

Use example with CompanySelect:

const CompanySelect = () => {
    const { changeCompany, loading, companies, selected } = useCompanyContext();
    if (loading) return <div>Loading...</div>;
    if (!companies || companies.length < 1)
        return <div>You are not part of a company</div>;

    return (
        <select
            value={selected}
            onChange={(evt) => changeCompany(parseInt(evt.target.value))}
        >
            {companies.map((company) => (
                <option value={company.id} key={company.id}>
                    {company.name}
                </option>
            ))}
        </select>
    );
};
SamiElk
  • 2,272
  • 7
  • 20