Search code examples
reactjsmvvmrenderingrenderinfinite-loop

React JS: My view model is causing an endless loop of rendering


I am new to React JS, coming from iOS and so trying to implement MVVM. Not sure it's the right choice, but that's not my question. I would like to understand how react works and what am I doing wrong. So here's my code:

const ViewContractViewModel = () => {
  console.log('creating view model');
  const [value, setValue] = useState<string | null>(null);
  const [isLoading, setisLoading] = useState(false);

  async function componentDidMount() {
    console.log('componentDidMount');
    setisLoading(true);
    // assume fetchValueFromServer works properly
    setValue(await fetchValueFromServer());
    console.log('value fetched from server');
    setisLoading(false);
  }

  return { componentDidMount, value, isLoading };
};

export default function ViewContract() {
  const { componentDidMount, value, isLoading } = ViewContractViewModel();
  useEffect(() => {
    componentDidMount();
  }, [componentDidMount]);

  return (
    <div className='App-header'>
      {isLoading ? 'Loading' : value ? value : 'View Contract'}
    </div>
  );
}

So here's what I understand happens here: the component is mounted, so I call componentDidMount on the view model, which invokes setIsLoading(true), which causes a re-render of the component, which leads to the view model to be re-initialised and we call componentDidMount and there's the loop.

How can I avoid this loop? What is the proper way of creating a view model? How can I have code executed once after the component was presented?

EDIT: to make my question more general, the way I implemented MVVM here means that any declaration of useState in the view model will trigger a loop every time we call the setXXX function, as the component will be re-rendered, the view model recreated and the useState re-declared.

Any example of how to do it right?

Thanks a lot!


Solution

  • A common pattern for in React is to use{NameOfController} and have it completely self-contained. This way, you don't have to manually call componentDidMount and, instead, you can just handle the common UI states of "loading", "error", and "success" within your view.

    Using your example above, you can write a reusable controller hook like so:

    import { useEffect, useState } from "react";
    import api from "../api";
    
    export default function useViewContractViewModel() {
      const [data, setData] = useState("");
      const [isLoading, setLoading] = useState(true);
      const [error, setError] = useState("");
    
      useEffect(() => {
        (async () => {
          try {
            const data = await api.fetchValueFromServer();
            setData(data);
          } catch (error: any) {
            setError(error.toString());
          } finally {
            setLoading(false);
          }
        })();
      }, []);
    
      return { data, error, isLoading };
    }
    

    Then use it within your view component:

    import useViewContractViewModel from "./hooks/useViewContractViewModel";
    import "./styles.css";
    
    export default function App() {
      const { data, error, isLoading } = useViewContractViewModel();
    
      return (
        <div className="App">
          {isLoading ? (
            <p>Loading...</p>
          ) : error ? (
            <p>Error: {error}</p>
          ) : (
            <p>{data || "View Contract"}</p>
          )}
        </div>
      );
    }
    

    Here's a demo:

    Edit Reusable Hook Example


    On a side note, if you want your controller hook to be more dynamic and can control the initial data set, then you can pass it props, which would then be added to the useEffect dependency array:

    import { useEffect, useState } from "react";
    import api from "../api";
    
    export default function useViewContractViewModel(id?: number) {
      const [data, setData] = useState("");
      const [isLoading, setLoading] = useState(true);
      const [error, setError] = useState("");
    
      useEffect(() => {
        (async () => {
          try {
            const data = await api.fetchValueFromServer(id);
            setData(data);
          } catch (error: any) {
            setError(error.toString());
          } finally {
            setLoading(false);
          }
        })();
      }, [id]);
    
      return { data, error, isLoading };
    }
    
    

    Or, you return a reusable callback function that allows you to refetch data within your view component:

    import { useCallback, useEffect, useState } from "react";
    import api from "../api";
    
    export default function useViewContractViewModel() {
      const [data, setData] = useState("");
      const [isLoading, setLoading] = useState(true);
      const [error, setError] = useState("");
    
      const fetchDataById = useCallback(async (id?: number) => {
        setLoading(true);
        setData("");
        setError("");
    
        try {
          const data = await api.fetchValueFromServer(id);
          setData(data);
        } catch (error: any) {
          setError(error.toString());
        } finally {
          setLoading(false);
        }
      }, [])
    
      useEffect(() => {
       fetchDataById()
      }, [fetchDataById]);
    
      return { data, error, fetchDataById, isLoading };
    }