Handling errors within Presentation components

Presentation rules - driven components get their data by making Presentation RPC requests, which may fail due to unexpected issues like network problems. Such failures, generally, result in an error being thrown in the frontend code, and, if not handled appropriately, may result in the whole application crash. For example, in such situation React applications render nothing and log something like this into browser's console:

Example error in browser console

As stated in the above error message, React suggests using error boundaries to handle such errors. A very simplistic error boundary component used in the below examples looks like this:

/**
 * A sample React error boundary which handles errors thrown by child components by merely
 * rendering the error message. Check out React's Error Boundary documentation for how to
 * implement a more elaborate solution.
 */
export class ErrorBoundary extends Component<{ children: React.ReactNode }, { error?: Error }> {
  public constructor(props: { children: React.ReactNode }) {
    super(props);
    this.state = {};
  }

  public static getDerivedStateFromError(error: Error) {
    // just save the error in the internal component's state
    return { error };
  }

  public override render() {
    // in case we got an error - render the error message
    if (this.state.error) {
      return this.state.error?.message ?? "Error";
    }

    // otherwise - render provided child component
    return this.props.children;
  }
}

Handling errors in property grid

Capturing property grid errors is as simple as wrapping rendering of the component with an error boundary:

function MyPropertyGrid(props: { imodel: IModelConnection; elementKey: InstanceKey }) {
  // create a presentation rules driven data provider; the provider implements `IDisposable`, so we
  // create it through `useOptionalDisposable` hook to make sure it's properly cleaned up
  const dataProvider = useOptionalDisposable(
    useCallback(() => {
      const provider = new PresentationPropertyDataProvider({ imodel: props.imodel });
      provider.keys = new KeySet([props.elementKey]);
      return provider;
    }, [props.imodel, props.elementKey]),
  );

  // width and height should generally we computed using ResizeObserver API or one of its derivatives
  const [width] = useState(400);
  const [height] = useState(600);

  if (!dataProvider) {
    return null;
  }

  // render the property grid within an error boundary - any errors thrown by the property grid will be captured
  // and handled by the error boundary
  return (
    <ErrorBoundary>
      <VirtualizedPropertyGridWithDataProvider dataProvider={dataProvider} width={width} height={height} />
    </ErrorBoundary>
  );
}

Result when there's no error:

Property grid without an error

Result when there's an error getting data for the property grid:

Property grid with an error

Handling errors in table

For the Table component, all requests are made by the usePresentationTable hook (or usePresentationTableWithUnifiedSelection when using it with Unified Selection). That means the hook needs to be used within the error boundary for it's errors to be captured. For that we use 2 components: one is responsible for rendering the table, the other - for wrapping it with an error boundary.

/** Props for `MyTable` and `MyProtectedTable` components */
interface MyTableProps {
  imodel: IModelConnection;
  keys: KeySet;
}

/** The actual table component that may throw an error */
function MyProtectedTable(props: MyTableProps) {
  // the `usePresentationTable` hook requests table data from the backend and maps it to something we
  // can render, it may also throw in certain situations
  const { columns, rows, isLoading } = usePresentationTable({
    imodel: props.imodel,
    ruleset,
    pageSize: 10,
    columnMapper: mapTableColumns,
    rowMapper: mapTableRow,
    keys: props.keys,
  });

  // either loading or nothing to render
  if (isLoading || !columns || !columns.length) {
    return null;
  }

  // render a simple HTML table
  return (
    <table>
      <thead>
        <tr>
          {columns.map((col, i) => (
            <td key={i}>{col.label}</td>
          ))}
        </tr>
      </thead>
      <tbody>
        {rows.map((row, ri) => (
          <tr key={ri}>
            {columns.map((col, ci) => (
              <td key={ci}>
                <Cell record={row[col.id]} />
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

/** A table component that renders the table within an error boundary */
function MyTable(props: MyTableProps) {
  // any errors thrown by `MyProtectedTable` will be captured and handled by the error boundary
  return (
    <ErrorBoundary>
      <MyProtectedTable {...props} />
    </ErrorBoundary>
  );
}

/** Cell renderer that uses `PropertyValueRendererManager` to render property values */
function Cell(props: { record: PropertyRecord | undefined }) {
  return <>{props.record ? PropertyValueRendererManager.defaultManager.render(props.record) : null}</>;
}

/** A function that maps presentation type of column definition to something that table renderer knows how to render */
const mapTableColumns = (columnDefinitions: TableColumnDefinition) => ({
  id: columnDefinitions.name,
  label: columnDefinitions.label,
});

/** A function that maps presentation type of row definition to something that table renderer knows how to render */
function mapTableRow(rowDefinition: TableRowDefinition) {
  const rowValues: { [cellKey: string]: PropertyRecord } = {};
  rowDefinition.cells.forEach((cell) => {
    rowValues[cell.key] = cell.record;
  });
  return rowValues;
}

Result when there's no error:

Table without an error

Result when there's an error getting data for the table:

Table with an error

Handling errors in tree

Tree component is slightly different from the above components, because it runs different queries to get data for different hierarchy levels. It's possible that we successfully get response for some of the requests, but fail for only one of them. In such situations, we want to show an error only where it happened, while still showing information that we successfully received.

At the moment this complexity is handled by PresentationTreeRenderer, which renders an "error node" for the whole hierarchy level upon an error, so there's no need to use an error boundary:

function MyTree(props: { imodel: IModelConnection }) {
  const state = usePresentationTreeState({
    imodel: props.imodel,
    ruleset,
    pagingSize: 100,
  });

  // width and height should generally we computed using ResizeObserver API or one of its derivatives
  const [width] = useState(400);
  const [height] = useState(600);

  if (!state) {
    return null;
  }

  // presentation-specific tree renderer takes care of handling errors when requesting nodes
  const treeRenderer = (treeRendererProps: TreeRendererProps) => <PresentationTreeRenderer {...treeRendererProps} nodeLoader={state.nodeLoader} />;

  return <PresentationTree width={width} height={height} state={state} selectionMode={SelectionMode.Extended} treeRenderer={treeRenderer} />;
}

Result when there's no error:

Example error in browser console

Result when an error occurs requesting child nodes:

Example error in browser console

Result when an error occurs requesting root nodes:

Example error in browser console

Last Updated: 01 May, 2024