Understanding act function
When writing RNTL tests one of the things that confuses developers the most are cryptic act() function errors logged into console. In this article I will try to build an understanding of the purpose and behaviour of act() so you can build your tests with more confidence.
act warning
Let's start with a typical act() warning logged to console:
Understanding act
Responsibility
This function is intended only for using in automated tests and works only in development mode. Attempting to use it in production build will throw an error.
The responsibility for act function is to make React renders and updates work in tests in a similar way they work in real application by grouping and executing related units of interaction (e.g. renders, effects, etc) together.
To showcase that behaviour let make a small experiment. First we define a function component that uses useEffect hook in a trivial way.
In the following tests we will directly use Test Renderer instead of RNTL render function to render our component for tests. In order to expose familiar queries like getByText we will use within function from RNTL.
When testing without act call wrapping rendering call, we see that the assertion runs just after the rendering but before useEffect hooks effects are applied. Which is not what we expected in our tests.
Note: In v14, act is now async by default and always returns a Promise. You should always use await act(...).
When wrapping rendering call with act we see that the changes caused by useEffect hook have been applied as we would expect.
When to use act
The name act comes from Arrange-Act-Assert unit testing pattern. Which means it's related to part of the test when we execute some actions on the component tree.
So far we learned that act function allows tests to wait for all pending React interactions to be applied before we make our assertions. When using act we get guarantee that any state updates will be executed as well as any enqueued effects will be executed.
Therefore, we should use act whenever there is some action that causes element tree to render, particularly:
- initial render call -
renderer.rendercall - re-rendering of component -
renderer.rendercall with updated element - triggering any event handlers that cause component tree render
Thankfully, for these basic cases RNTL has got you covered as our render, rerender and fireEvent methods already wrap their calls in act so that you do not have to do it explicitly. In v14, these functions are all async and should be awaited.
Note that act calls can be safely nested and internally form a stack of calls.
Implementation
The act implementation is defined in the ReactAct.js source file inside React repository. RNTL v14 requires React 19+, which provides the act function directly via React.act.
RNTL exports act for convenience of the users as defined in the act.ts source file. In v14, act is now async by default and always returns a Promise, making it compatible with async React features like Suspense boundary or use() hook. The underlying implementation wraps React's act function to ensure consistent async behavior.
Important: You should always use act exported from @testing-library/react-native rather than the one from react. The RNTL version automatically ensures async behavior, whereas using React.act directly could still trigger synchronous act behavior if used improperly, leading to subtle test issues.
Asynchronous code
In v14, act is always async and returns a Promise. While the callback you pass to act can be synchronous (dealing with things like synchronous effects or mocks using already resolved promises), the act function itself should always be awaited. However, not all component code is synchronous. Frequently our components or mocks contain some asynchronous behaviours like setTimeout calls or network calls.
Handling asynchronous operations
When the callback passed to act contains asynchronous operations, the Promise returned by act will resolve only after those operations complete.
Lets look at a simple example with component using setTimeout call to simulate asynchronous behaviour:
If we test our component in a native way without handling its asynchronous behaviour we will end up with an act warning. This is because the setTimeout callback will trigger a state update after the test has finished.
Solution with fake timers
First solution is to use Jest's fake timers inside out tests:
Note: In v14, both render and act are async by default, so you should await them.
That way we can wrap jest.runAllTimers() call which triggers the setTimeout updates inside an act call, hence resolving the act warning.
Solution with real timers
If we wanted to stick with real timers then things get a bit more complex. Let's start by applying a crude solution of opening async act() call for the expected duration of components updates:
This works correctly as we use an explicit async act() call that resolves the console error. However, it relies on our knowledge of exact implementation details which is a bad practice.
Let's try more elegant solution using waitFor that will wait for our desired state:
This also works correctly, because waitFor call executes async act() call internally.
The above code can be simplified using findBy query:
This also works since findByText internally calls waitFor which uses async act().
Note that all of the above examples are async tests using & awaiting async act() function call.
