Lifecycle
We’ve discussed some concepts about Pract’s virtual GUI tree—Elements are mounted in a host context, can be updated by being replaced with new elements, and can be unmounted to clean up UI components and instances.
Certain points on the timeline between a component or instance being mounted and unmounted is called its lifecycle, and we can write components that perform special behaviors during parts of their lifecycle.
The Pract.lifecycle
takes in a closure function (which is called once when the component is mounted), and should return a table of type Pract.Lifecycle
. This table must have a render
function which is a component.
local MyLifecycleComponent = Pract.withLifecycle(function(): Pract.Lifecycle
return {
render = function(props: {text: string})
return Pract.create('TextLabel', {
Text = text,
})
end,
}
end)
local myElement = Pract.create(MyLifecycleComponent, {text = 'Hello, Lifecycle!'})
The code above acts equivalently to the components we’ve seen before. Let’s add what’s called a lifecycle hook, and print something just before a component finishes mounting:
local MyLifecycleComponent = Pract.withLifecycle(function(): Pract.Lifecycle
return {
init = function(props: {text: string})
print("We are mounting our lifecycle component with text", props.text)
end,
render = function(props: {text: string})
print("We are rendering our lifecycle component with text", props.text)
return Pract.create('TextLabel', {
Text = text,
})
end,
}
end)
If we mount this component, then update it, it should only print our mounting message once, and our rendering message twice:
(Image coming soon when roblox isn’t down)
The following hooks can be used in a lifecycle component: init
, render
, didMount
, shouldUpdate
, willUpdate
, didUpdate
, willUnmount
Mounting process in Pract:
- Pract is told to mount an element (via
Pract.mount
or otherwise) - The
withLifecycle
closure function is called, and the Lifecycle table is returned init
is called with the first props passed into the componentrender
is called to get the component’s elements- Gui instances are created/destroyed/modified to match the elements returned by the render component
didMount
is called with the first props passed into the component
Updating proccess in Pract:
- An update is triggered on a mounted component (via
Pract.update
, or otherwise) shouldUpdate
function on the Lifecycle is called if it exists. This function takes in the new and old props, and should return a boolean (true or false). If it returns true, we continue updating; if it returns false, we stop the update immediately.willUpdate
is called with the new propsrender
is called to get the component’s updated elements- Gui instances are created/destroyed/modified to match the elements returned by the render component
didUpdate
is called with the new props
Unmounting proccess in Pract:
- An unmount is triggered on a mounted component (via
Pract.unmount
, or otherwise) willUnmount
function on the Lifecycle is called if it exists. This function takes in the new and old props, and should return a boolean (true or false). If it returns true, we continue updating; if it returns false, we stop the update immediately.willUpdate
is called with the new propsrender
is called to get the component’s updated elements- Gui instances are created/destroyed/modified to match the elements returned by the render component
didUpdate
is called with the new props
Putting it all together
In addition to all of this, the withLifecycle
closure is passed in a function called forceUpdate
, which will trigger an update on the nested Lifecycle component when called. This will automatically be throttled to happen once per frame at most.
A completely filled-out lifecycle component which uses all lifecycle hooks will look like this:
type Props = {}
local MyLifecycleComponent = Pract.withLifecycle(function(forceUpdate): Pract.Lifecycle
print("Closure called")
return {
init = function(firstProps: Props)
print("Init")
end,
didMount = function(firstProps: Props)
print("Did mount")
end,
render = function(props: Props)
print("Render") -- Note: You should avoid having side effects in the render function
-- itself.
return Pract.create('Frame')
end,
shouldUpdate = function(props: Props)
print("Should update?")
return true -- If we return false here, an update will abort.
end,
willUpdate = function(props: Props)
print("Will update")
end,
didUpdate = function(props: Props)
print("Did update")
end,
willUnmount = function(lastProps: Props)
print("Will unmount")
end,
}
end)
Incrementing Counter using State and Lifecycle
--!strict
local Pract = require(game.ReplicatedStorage.Pract)
local function Clock(props: {time: number})
return Pract.create("ScreenGui", {}, {
TimeLabel = Pract.create("TextLabel", {
Size = UDim2.new(1, 0, 1, 0),
Text = "Time Elapsed: " .. props.time
})
})
end
-- This component will begin a loop that upticks our state every second until it is unmounted!
local Upticker = Pract.withDeferredState(function(getTime, setTime)
-- Initialize our time state
setTime(0)
-- Because Pract.withLifecycle returns a component, we can nest it here!
return Pract.withLifecycle(function())
local mounted = true
return {
init = function()
-- Start a new thread here which ends when the component is unmounted
task.spawn(function()
repeat
task.wait(1)
setTime(getTime() + 1) -- This will automatically trigger an update.
until not mounted
end)
end,
render = function(props: {}) -- This render component can depend on both state and props
return Pract.create(Clock, {
time = getTime()
})
end,
willUnmount = function()
mounted = false
end,
}
end)
end)
local PlayerGui = game.Players.LocalPlayer:WaitForChild('PlayerGui')
local handle = Pract.mount(Pract.create(Upticker), PlayerGui, "Clock UI")
Note how in this example, we never have to call Pract.update
! That is because all state-based updates are contained within the Upticker component intself.