Skip to content

Adopt New Features

Roact 17 ships with a number of features new to Roact that have been ported from React JS, in addition to a couple of new capabilities unique to the Roact ecosystem.

Asynchronous Rendering

Roact 17 introduces a paradigm shift to the underlying rendering behavior that allows it to divide work across multiple frames and preserve high framerate and interactivity.

This behavior is called Concurrent Mode rendering.

Roact 17 will use Concurrent Mode by default in its mount compatibility layer.

Info

Roact 17 is aligned to React JS 17.0.1, which means that it has not inherited any changes to Concurrent Mode described in the React 18 documentation. As we begin to build Roact 18, we may shift our distinctions similarly. For now, opting into Concurrent Mode is the best way to get the latest optimizations and ensure that your components are robust.

ReactRoblox.createRoot

In legacy Roact, the Roact.mount function is used to render a component tree. In React JS 17 and older, the primary entry-point for rendering a component tree is ReactDOM.render, while the experimental ReactDOM.createRoot API is used to adopt Concurrent Mode.

Roact 17 skips introducing the top-level render function from earlier versions of React JS, and instead provides the following for mounting Roact UI elements:

  • The createRoot, createBlockingRoot, and createLegacyRoot APIs from React JS 17
  • A compatibility layer that exports a mount function aligned with legacy Roact's API

In new code, you should always use ReactRoblox.createRoot. The vast majority of existing Roact code should be compatible with createRoot, which enables Concurrent Mode and can greatly improve responsiveness for complex applications.

If you run into problems with Concurrent Mode that are difficult to address, you may be interested in considering ReactRoblox.createBlockingRoot or ReactRoblox.createLegacyRoot at a cost to overall app responsiveness. Reach out to #lua-foundation before proceeding with any new code using these APIs.

ReactRoblox.act

When Concurrent Mode is enabled, Roact will attempt to schedule work evenly across rendering frames to keep the application running smoothly, even when UI rendering work has been queued up.

However, this means that tests relying on synchronous rendering behavior will no longer function correctly. To fix this, use the ReactRoblox.act function to play scheduler logic forward (also re-exported as RoactCompat.act).

How to Use act

The ReactRoblox.act utility works by:

  1. Running the provided function
  2. Performing queued work by playing forward Roact's internal scheduler
  3. Repeating step 2 until the queue is empty

When running tests using the mock scheduler, the following scenarios will need to be wrapped in an act call:

  • Rendering an initial tree with the render method of a React root or the RoactCompat.mount compatibility function (if the __ROACT_17_INLINE_ACT__ global is set to true, this will happen automatically for mount, update, and unmount)
  • Rendering updates with the render method of a React root
  • Unmounting a tree by passing nil to the render method of a React root
  • Calling task.wait or other yielding functions to allow engine callbacks to fire
  • Triggering behavior that causes a component to update its state, including firing signals that your component has subscribed to
  • Processing user inputs generated by Rhodium
    • Keep in mind that most input events only fire if the target elements meet certain requirements (on screen, visible, enabled).
    • Virtual input events may need to be explicitly flushed before events fire and relevant Roact work is queued (see example below)

Info

In order to enable the act function, you'll need Roact to use the mocked version of its internal scheduler. To do this, set the __ROACT_17_MOCK_SCHEDULER__ global to true in your testing configuration.

Example

Many integration tests involve validating various states of a component and interacting with the component via virtual input events or mocked signals. The following is a comprehensive example of several of the above scenarios in practice.

Suppose we have a component called TooltipButton:

local function TooltipButton(props)
    local showTooltip, setShowTooltip = React.useState(false)

    return React.createElement("Frame", {
        key = "TooltipButton",
        Size = UDim2.fromScale(1, 1),
    }, {
        Button = React.createElement("TextButton", {
            Text = "Show tooltip",
            Active = props.enabled,
            [React.Event.Activated] = function()
                setShowTooltip(true)
                task.delay(props.tooltipFadeDelay, function()
                    setShowTooltip(false)
                end)
            end,
        })
        Tooltip = if showTooltip
            then React.createElement("TextLabel", {
                Text = "Tooltip text!",
            })
            else nil
    })
end

We'd like to write a test to validate the tooltip behavior. Here's how we might use act to guarantee that all scheduled work is flushed and our test works as expected:

local React = require(Packages.React)
local ReactRoblox = require(Packages.ReactRoblox)

local container, root
beforeEach(function()
    container = Instance.new("ScreenGui")
    container.Parent = Players.LocalPlayer.PlayerGui

    root = ReactRoblox.createRoot(container)
end)

afterEach(function()
    container:Destroy()
end)

it("shows a tooltip on click and hides it after a delay", function()
    -- Use `act` for the initial render
    ReactRoblox.act(function()
        -- Render the button in a disabled state
        root:render(React.createElement(TooltipButton, {
            enabled = false,
            tooltipFadeDelay = 1,
        }))
    end)

    expect(container.TooltipButton.Tooltip).toBeNil()
    expect(container.TooltipButton.Button.Active).toBe(false)

    -- Use `act` to re-render the tree
    ReactRoblox.act(function()
        -- Rerender in the enabled state
        root:render(React.createElement(TooltipButton, {
            enabled = true,
            tooltipFadeDelay = 1,
        }))
    end)

    expect(container.TooltipButton.Tooltip).toBeNil()
    expect(container.TooltipButton.Button.Active).toBe(true)

    -- Use `act` to trigger virtual input
    local element = Rhodium.Element.new(container.TooltipButton.Button)
    ReactRoblox.act(function()
        -- Click the button to trigger the tooltip
        element:click()
        Rhodium.VirtualInput.waitForInputEventsProcessed()
    end)

    expect(container.TooltipButton.Tooltip).never.toBeNil()
    expect(container.TooltipButton.Tooltip.Text).toEqual("Tooltip text!")

    -- Use `act` to resume queued renders after a delayed callback fires
    ReactRoblox.act(function()
        task.wait(1)
    end)

    expect(container.TooltipButton.Tooltip).toBeNil()
end)

For more details and examples, refer to documentation on the act function in React JS.

Info

In new test code, consider adopting libraries like dom-testing-library-lua and react-testing-library-lua, which are ports of JS testing libraries that handle much of the act logic for you. Reach out to #lua-foundation on slack for the status of these libraries.

Hooks

While async rendering is a paradigm shift for under-the-hood rendering behavior, hooks are a paradigm shift for component development. They're designed to be a more performant, composable, testable, and ergonomic approach to defining stateful behavior and side effects (relative to to class component lifecycle methods).

You can access hooks via the React Package. In order to encourage migration to the new package conventions, RoactCompat does not export the hooks API. Follow the previous section to set up your dependencies appropriately.

An excellent and comprehensive guide for hooks can be found in the React JS documentation; the example below will help illustrate what they look like when used in Luau.

Hooks Example

The following is a simple example that uses two of the most common hooks: useState and useEffect.

local React = require(Packages.React)

function ClickerComponent(props)
    local count, setCount = React.useState(0)
    local function onClick()
        setCount(function(oldCount)
             return oldCount + 1
        end)
    end

    React.useEffect(function()
        print(string.format("You've clicked %d times!", count))
    end)

    return React.createElement("TextButton", {
        Text = tostring(count),
        [React.Event.Activated] = onClick,
    })
end

Check the API Reference to see the complete list of hooks supported by Roact 17.

Utilities

React.memo

The memo function can be used to memoize a function component, returning the same element if the same props are provided. This may be a helpful optimization in scenarios where a function component frequently re-renders without changes due to its parent re-rendering.

Refer to the React JS documentation for more details and examples.

React.Children

Under construction 🔨

Roact-Only Features

In addition to the new features provided by aligning with React JS 17, a few new features have been added to extend and improve support for legacy Roact features.

useBinding Hook

Legacy Roact introduces a concept called "bindings", a shorthand for the concept of "unidirectional data bindings." You can learn more about bindings in the legacy Roact documentation.

The legacy version of bindings were created similarly to refs, using a createBinding method that would be called in the init method of a class component. So how do we use bindings with function components?

Roact 17 introduces, alongside the core hooks from React JS 17, an additional useBinding hook to address this case. You can use it much like a useState hook, except it will follow binding semantics: instead of triggering component re-renders, binding updates will directly update subscribed values.

local React = require(Packages.React)

function ClickerComponent(props)
    local count, setCount = React.useBinding(0)
    local function onClick()
        -- This will only update subscribed host properties, specifically the
        -- Text field of the button that's rendered below
        setCount(count:getValue() + 1)
    end

    React.useEffect(function()
        print(string.format("You've clicked %d times!", count))
    end)

    return React.createElement("TextButton", {
        Text = count:map(tostring),
        [React.Event.Activated] = onClick,
    })
end

React.Tag

Under Construction 🔨