Skip to content

Updating Conventions and APIs

Almost all legacy Roact code can be patched to Roact 17 using the RoactCompat library. However, while RoactCompat is backwards compatible with with Legacy Roact, it does not export new Roact 17 features like hooks.

When writing code exclusively for Roact 17, you should access the primary APIs of Roact 17 through the React and ReactRoblox APIs, instead of through RoactCompat.

  • React exposes:
    • some core legacy Roact APIs unchanged (createElement, createContext, Component, Change/Event, etc.)
    • some React-JS-aligned updates to other legacy Roact APIs (createFragment -> Fragment, oneChild -> Children.only, etc.)
    • and several brand new APIs (useState, useRef, memo, cloneElement, etc.)
  • ReactRoblox exposes:
    • the createRoot API, which replaces the mount/update/unmount APIs
    • the createPortal API, which replaces the Roact.Portal component

To reduce confusion, try to avoid using RoactCompat and React/ReactRoblox in the same file. If you are adding new Roact features to code that uses RoactCompat syntax, take the following steps to align conventions:

  1. Replace the Roact import with the React import
  2. Swap all uses of Roact with React
  3. If the module uses any Roact.Portal instances, import the ReactRoblox package and replace them with ReactRoblox.createPortal
  4. If the module uses mount/update/unmount, import the ReactRoblox package to replace them
    • Replace Roact.mount with ReactRoblox.createRoot followed by root:render
    • Replace Roact.update with subsequent calls to root:render
    • Replace Roact.unmount by calling root:unmount
  5. Begin adopting new features exported from the React package as needed

API Conversions

Roact 17 adopts the naming conventions and API shape of React JS wherever possible, with small deviations made to accommodate the distinct features of the Luau language. Using new naming conventions and APIs helps to align with the React JS ecosystem and reduce friction and mental overhead when troubleshooting or browsing documentation.

Even though Roact 17 is designed to be largely compatible with legacy Roact, updating these conventions when adopting new features is highly encouraged.

Mounting, Updating, and Unmounting

Legacy Roact uses three functions to manage the lifecycle of an entire Roact tree composed of numerous components. This is typically the entry point into Roact-managed UI.

In Roact 17, the equivalent mechanisms use a new concept called a "root" to more clearly encapsulate this behavior.

  • ReactRoblox.createRoot
  • ReactRoblox.createBlockingRoot
  • ReactRoblox.createLegacyRoot

Since the root needs to access a Roblox Instance in order to attach to it, these functions are exported through the ReactRoblox package, also known as a "renderer". A renderer takes the abstract descriptions of UI generated by React components and turns them into a concrete UI element tree. You can think of the ReactRoblox package as the semantic equivalent of the react-dom package in the React JS ecosystem.

Legacy Roact

local PlayerGui = game:GetService("Players").LocalPlayer.PlayerGui
local Roact = require(Packages.Roact)

local roactTree = Roact.mount(Roact.createElement("TextLabel", {
    Text = "Hello world!",
}, PlayerGui)

task.wait(3)

roactTree = Roact.update(roactTree, Roact.createElement("TextLabel", {
    Text = "Hello Roblox!",
})

task.wait(3)

Roact.unmount(roactTree)

Roact 17

local PlayerGui = game:GetService("Players").LocalPlayer.PlayerGui
local React = require(Packages.React)
local ReactRoblox = require(Packages.ReactRoblox)

-- Roact 17 roots will take full ownership of the instance provided to them,
-- so we should not create a root using PlayerGui directly
local container = Instance.new("Folder")
container.Parent = PlayerGui

local root = ReactRoblox.createRoot(container)
root:render(Roact.createElement("TextLabel", {
    Text = "Hello world!",
})

task.wait(3)

root:render(Roact.createElement("TextLabel", {
    Text = "Hello Roblox!",
})

task.wait(3)

root:render(nil)

Warning

The createBlockingRoot and createLegacyRoot functions will opt out of concurrent rendering, a feature of Roact 17 that allows smooth, scalable UI by dividing units of work across multiple frames when large amounts of UI changes are needed. We always recommend using createRoot unless you know what you're doing.

Component Lifecycle Names

Legacy Roact used simplified names for component lifecycle functions to alleviate redundancy when defining lifecycle methods with syntax like function MyComponent:didMount() ... end.

However, Roact 17 prefers to align as close as possible to React JS to reduce friction when referring to resources from the React JS ecosystem (questions, tutorials, examples, documentation, etc.). It's recommended to update class component lifecycle names to match React JS.

  • didMountcomponentDidMount
  • shouldUpdateshouldComponentUpdate
  • didUpdatecomponentDidUpdate
  • didMountcomponentDidMount
  • willUpdateUNSTABLE_componentWillUpdate
    • It's recommended to use the UNSTABLE_ prefix here, since the componentWillUpdate method is deprecated
  • willUnmountcomponentWillUnmount

Reserved Keys

Legacy Roact went out of its way to let all prop keys be valid for component developers to use. In Roact 17, a small subset of prop keys are reserved for internal use to make them simple to provide.

  • [Roact.Ref] becomes ref
  • [Roact.Children] becomes children

You can index props.children to refer to the children provided to a component, and use ref as a key to an element to provide a ref to it.

Legacy Roact

local FocusButton = Roact.Component:extend("FocusButton")

function FocusButton:init()
    self.ref = Roact.createRef()
end

function FocusButton:render()
    return Roact.createElement("Button", {
        Size = self.props.Size,
        [Roact.Ref] = self.ref
    }, self.props[Roact.Children])
end

function FocusButton:didMount()
    GuiService.SelectedObject = self.ref.current
end

Roact 17

local FocusButton = Roact.Component:extend("FocusButton")

function FocusButton:init()
    self.ref = Roact.createRef()
end

function FocusButton:render()
    return Roact.createElement("Button", {
        Size = self.props.Size,
        ref = self.ref
    }, self.props.children)
end

function FocusButton:didMount()
    GuiService.SelectedObject = self.ref.current
end

Context.Consumer

Legacy Roact treats the special Context.Consumer component generated by createContext more like a typical component with a single prop (render) as its prop interface. React JS, on the other hand, uses a slightly abbreviated structure.

The Context.Consumer component expects no props and a single child, where the child is the same mapping function that would be provided as the render prop in legacy Roact.

Legacy Roact

local ThemeContext = Roact.createContext(nil)

-- ...

local function Button(props)
    return Roact.createElement(ThemeContext.Consumer, {
        render = function(theme)
            return Roact.createElement("TextButton", {
                BackgroundColor3 = theme.ButtonColor,
                Text = props.text,
                [Roact.Event.Activated] = props.onActivated,
            })
        end
    })
end

Roact 17

local ThemeContext = Roact.createContext(nil)

-- ...

local function Button(props)
    return Roact.createElement(ThemeContext.Consumer, nil,
        function(theme)
            return Roact.createElement("TextButton", {
                BackgroundColor3 = theme.ButtonColor,
                Text = props.text,
                [Roact.Event.Activated] = props.onActivated,
            })
        end
    )
end

Info

Roact 17 has a compatibility layer that will allow either structure to work as expected. If you'd like to see warnings that will help you migrate, enable the __COMPAT_WARNINGS__ global.

React.Fragment

Legacy Roact treats fragments as a special kind of rendered object, distinct from a Roact element, using the Roact.createFragment function.

In Roact 17, however, fragments are nothing more than a special component type. Your component should return a single element with the React.Fragment component type, with the children provided as the element's children.

Legacy Roact

local function LabeledButton(props)
    return Roact.createFragment({
        Button = Roact.createElement("ImageButton", {
            Image = props.buttonIcon,
        }),
        Label = Roact.createElement("TextLabel", {
            Text = props.labelText,
        }),
    })
end

Roact 17

local function LabeledButton(props)
    return React.createElement(React.Fragment, nil, {
        Button = React.createElement("ImageButton", {
            Image = props.buttonIcon,
        }),
        Label = React.createElement("TextLabel", {
            Text = props.labelText,
        }),
    })
end

ReactRoblox.createPortal

Legacy Roact uses a special Roact.Portal component to represent a portal. In Roact 17, we align with upstream and export the ReactRoblox.createPortal function.

Portals are considered to be a feature with renderer-specific functionality since they attach directly to a host instance. Any APIs that interact directly with Roblox Instances will be exported via ReactRoblox in order to maintain the renderer abstraction.

Legacy Roact

local PlayerGui = game:GetService("Players").LocalPlayer.PlayerGui

local function Modal(props)
    return Roact.createElement(Roact.Portal, {
        target = PlayerGui,
    }, {
        Modal = Roact.createElement("ScreenGui", {}, {
            Label = Roact.createElement("TextButton", {
                Text = "Click me to close!",
                [Roact.Event.Activated] = props.onClose,
            })
        })
    })
end

Roact 17

local PlayerGui = game:GetService("Players").LocalPlayer.PlayerGui

local function Modal(props)
    return ReactRoblox.createPortal({
        Modal = Roact.createElement("ScreenGui", {}, {
            Label = Roact.createElement("TextButton", {
                Text = "Click me to close!",
                [Roact.Event.Activated] = props.onClose,
            })
        })
    }, PlayerGui)
end

Roact.oneChild

Legacy Roact provides an infrequently-used utility function called oneChild that guarantees that the argument provided to it is a table with only one child Roact element. If the provided table contains zero elements or two or more elements, oneChild will throw an error.

Roact 17 inherits a somewhat similar function from React JS: React.Children.only. This function will throw an error if the provided argument is not itself a single child element. In other words, it expects a single child instead of a table containing only one child. This can be converted by unwrapping the provided children manually before passing them along to React.Children.only.

Limiting a component to a single child isn't always necessary. Consider whether your component is capable of supporting an arbitrary number of children after all. In that case, you can simply remove the call to Roact.oneChild.

Eliminating RoactCompat

Once you've completed all of the above conversions, you should no longer need to rely on the RoactCompat package in your module. Congratulations, your migration is complete!