Skip to content

Instantly share code, notes, and snippets.

@grilme99
Created September 21, 2025 00:07
Show Gist options
  • Select an option

  • Save grilme99/5aa18b29e7a3851a57d57517d09576e4 to your computer and use it in GitHub Desktop.

Select an option

Save grilme99/5aa18b29e7a3851a57d57517d09576e4 to your computer and use it in GitHub Desktop.
ReactFreeze.luau

🧊 React Freeze

Prevent React component subtrees from rendering.

What is this?

This library lets you freeze the renders of parts of the React component tree using Suspense mechanism introduced in React 17. The main use case of this library is to avoid unnecessary re-renders of parts of the app that are not visible to the user at a given moment. The frozen components are not unmounted when they are replaced with a placeholder view, so their React state and corresponding Roblox Instances are retained during the freeze, keeping things like scroll position and input state unchanged.

With react-freeze, we are able to suspend renders for the hidden screens and, as a result, save React from doing unnecessary computation (such as reconciliation, sending view change updates, etc.).

This Luau library is ported from Software Mansion's react-freeze.

Quick Start

Wrap some components you want to freeze and pass freeze option to control whether renders in that components should be suspended:

local function SomeComponent(props: { shouldSuspendRendering: boolean })
    return e(Freeze, {
        freeze = props.shouldSuspendRendering
    }, {
        MyOtherComponent = e(MyOtherComponent),
    })
end

Component Docs

The react-freeze library exports a single component called <Freeze>. It can be used as a boundary for components for which we want to suspend rendering. This takes the following options:

freeze: boolean

This options can be used to control whether components rendered under Freeze should or should not re-render. If set to true, all renders for components from the Freeze subtree will be suspended until the prop changes to false.

placeholder: React.ReactNode?

This parameter can be used to customize what the Freeze component should render when it is in the frozen state (freeze = true). This is an optional parameter and by default it renders a Fragment (aka nothing). Note, that it is best to "freeze" only components that are not visible to the user at given moment, so in general customizing this should not be necessary. However, if replacing frozen views with just a Fragment can break layout of you app, you can use this parameter to show a placeholder that will keep all non-frozen parts of your application in the same place.

FAQ

When the component subtree is frozen, what happens to state changes that are executed on that subtree

All state changes are executed as usual, they just won't trigger a render of the updated component until the component comes back from the frozen state.

What happens to the non-React state of the component after defrost? Like for example scroll position?

Since all the Roblox Instances are kept when the component is frozen, their state (such as scroll position, text typed into text input fields, etc.) is restored when they come back from the frozen state.

What happens when there is an update in a Rodux store that the frozen component is subscribed to?

Rodux and other state-management solutions rely on manually triggering re-renders for components that they want to update when the store state changes. When component is frozen it won't render even when Rodux requests it, however methods such as mapStateToProps or selectors provided to useSelector will still run on store updates. After the component comes back from frozen state, it will render and pick up the most up-to-date data from the store.

Can freezing some of my app components break my app? And how?

There are few ways <Freeze> can alter the app behavior:

  1. When attempting to freeze parts of the app that is visible to the user at a given moment -- in this case the frozen part is going to be replaced by the placeholder (or just by nothing if no placeholder is provided). So unless you really want this behavior make sure to only set freeze to true when the given subtree should not be visible and you expect the user to not interact with it. A good example are screens on the navigation stack that are down the stack hierarchy when you push more content.
  2. When you rely on the frozen parts of the layout to properly position the unfrozen parts. Note that when a component is in a frozen state it gets replaced by a placeholder (or by nothing if you don't provide one). This may impact the layout of the rest of your application. This can be workaround by making the placeholder take the same amount of space as the view it replaces, or by only freezing parts that are positioned absolutely (e.g. the component that takes up the whole screen).
  3. When the component render method has side-effects that relay on running for all prop/state updates. Typically, performing side-effects in render is undesirable when writing React code but it can happen in your codebase nonetheless. Note that when the subtree is frozen your component may not be rendered for all the state updates and render method won't execute for some of the changes that happen during that phase. However, when the component gets back from the frozen state it will render with the most up-to-date version of the state, and if that suffice for the side-effect logic to be correct you should be ok.
local PackageRoot = script:FindFirstAncestor("ReactFreeze")
local Packages = PackageRoot.Parent
local React = require(Packages.React)
local e = React.createElement
local infiniteThenable = {
andThen = function() end,
}
local function Suspender(props: {
freeze: boolean,
children: React.ReactNode,
})
if props.freeze then
error(infiniteThenable)
else
return e(React.Fragment, nil, props.children)
end
end
type Props = {
freeze: boolean,
children: React.ReactNode,
placeholder: React.ReactNode?,
}
local function Freeze(props: Props)
return e(React.Suspense, {
fallback = props.placeholder or e(React.Fragment),
}, {
Suspender = e(Suspender, {
freeze = props.freeze,
}, props.children),
})
end
return table.freeze({
Freeze = Freeze,
})
local PackageRoot = script:FindFirstAncestor("ReactFreeze")
local Packages = PackageRoot.Parent
local React = require(Packages.React)
local ReactTestRenderer = require(Packages.Dev.ReactTestRenderer)
local JestGlobals = require(Packages.Dev.JestGlobals)
local it = JestGlobals.it
local expect = JestGlobals.expect
local Freeze = require(script.Parent.ReactFreeze).Freeze
local e = React.createElement
local useState = React.useState
local useEffect = React.useEffect
local create = ReactTestRenderer.create
local act = ReactTestRenderer.act
it("renders stuff not frozen", function()
local function Content()
return e("Frame")
end
local function A()
return e(Freeze, {
freeze = false,
}, {
Content = e(Content),
})
end
local testRenderer = create(e(A))
local testInstance = testRenderer.root
expect(testInstance:findByType(Content)).toBeTruthy()
end)
it("does not render stuff when frozen", function()
local function Content()
return e("Frame")
end
local function A()
return e(Freeze, {
freeze = true,
}, {
Content = e(Content),
})
end
local testRenderer = create(e(A))
local testInstance = testRenderer.root
expect(testInstance:findAllByType(Content)).toHaveLength(0)
end)
it("removes stuff after freeze", function()
local function Content()
return e("Frame")
end
local function A(props: { freeze: boolean })
return e(Freeze, {
freeze = props.freeze,
}, {
Content = e(Content),
})
end
local testRenderer
act(function()
testRenderer = create(e(A, { freeze = false }))
end)
local testInstance = testRenderer.root
expect(testInstance:findByType(Content)).toBeTruthy()
act(function()
testRenderer.update(e(A, { freeze = true }))
end)
expect(testRenderer.toJSON()).toBe(nil)
end)
it("updates work when not frozen", function()
local subscription
local function Inner(_props: { value: number })
return e(React.Fragment)
end
local renderCount = 0
local function Subscriber()
local value, setValue = useState(0)
useEffect(function()
subscription = setValue
end, {})
renderCount += 1
return e(Inner, { value = value })
end
local function Container(props: { freeze: boolean })
return e(Freeze, {
freeze = props.freeze,
}, {
Subscriber = e(Subscriber),
})
end
local testRenderer
act(function()
testRenderer = create(e(Container, { freeze = false }))
end)
local testInstance = testRenderer.root
expect(testInstance:findByType(Inner).props.value).toBe(0)
act(function()
subscription(1)
end)
expect(testInstance:findByType(Inner).props.value).toBe(1)
expect(renderCount).toBe(2)
end)
it("does not propagate updates when frozen", function()
local subscription
local function Inner(_props: { value: number })
return e(React.Fragment)
end
local renderCount = 0
local function Subscriber()
local value, setValue = useState(0)
useEffect(function()
subscription = setValue
end, {})
renderCount += 1
return e(Inner, { value = value })
end
local function Container(props: { freeze: boolean })
return e(Freeze, {
freeze = props.freeze,
}, {
Subscriber = e(Subscriber),
})
end
local testRenderer
act(function()
testRenderer = create(e(Container, { freeze = false }))
end)
local testInstance = testRenderer.root
expect(testInstance:findByType(Inner).props.value).toBe(0)
act(function()
testRenderer.update(e(Container, { freeze = true }))
end)
act(function()
subscription(1)
end)
expect(testInstance:findByType(Inner).props.value).toBe(0)
expect(renderCount).toBe(1)
end)
it("persists state after defrost", function()
local subscription
local function Inner(_props: { value: number })
return e("Frame")
end
local function Subscriber()
local value, setValue = useState(0)
useEffect(function()
subscription = setValue
end, {})
return e(Inner, { value = value })
end
local function Container(props: { freeze: boolean })
return e(Freeze, {
freeze = props.freeze,
}, {
Subscriber = e(Subscriber),
})
end
local testRenderer
act(function()
testRenderer = create(e(Container, { freeze = false }))
end)
local testInstance = testRenderer.root
expect(testInstance:findByType(Inner).props.value).toBe(0)
act(function()
subscription(1)
end)
expect(testInstance:findByType(Inner).props.value).toBe(1)
act(function()
testRenderer.update(e(Container, { freeze = true }))
end)
expect(testRenderer.toJSON()).toBe(nil)
act(function()
testRenderer.update(e(Container, { freeze = false }))
end)
expect(testRenderer.toJSON().type).toBe("Frame")
expect(testInstance:findByType(Inner).props.value).toBe(1)
end)
it("propagates updates after defrost", function()
local subscription
local function Inner(_props: { value: number })
return e("Frame")
end
local renderCount = 0
local function Subscriber()
local value, setValue = useState(0)
useEffect(function()
subscription = setValue
end, {})
renderCount += 1
return e(Inner, { value = value })
end
local function Container(props: { freeze: boolean })
return e(Freeze, {
freeze = props.freeze,
}, {
Subscriber = e(Subscriber),
})
end
local testRenderer
act(function()
testRenderer = create(e(Container, { freeze = false }))
end)
local testInstance = testRenderer.root
act(function()
testRenderer.update(e(Container, { freeze = true }))
end)
act(function()
subscription(1)
end)
act(function()
subscription(2)
end)
act(function()
subscription(3)
end)
expect(testInstance:findByType(Inner).props.value).toBe(0)
act(function()
testRenderer.update(e(Container, { freeze = false }))
end)
expect(testInstance:findByType(Inner).props.value).toBe(3)
expect(renderCount).toBe(2)
end)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment