Skip to main content

Lifecycle hooks

Beyond Run, a command module can declare any of five optional hooks. Every hook is plugin-managed: no :Connect, no :Disconnect, no signal cleanup. Define the field on the returned table and CommandRunner calls it at the right moment.

HookSignatureFires when
Validate(props) -> boolean, string?Before Run (Execute button or RunCommand).
OnExecuted(props, arguments, result)After Run returns successfully.
OnError(props, arguments, err)After Run throws.
OnSelected(props)The user picks the command in the sidebar.
OnDeselected(props)The user picks another command, deletes this one, or closes the widget.

All five are optional. All five are caught in a pcall — an error inside a hook is logged with warn and never propagates.

Validate and OnError apply to the main Run path only — they do not fire for per-argument Run callbacks (the ones declared on individual button / switch / etc. arguments).

Validate

Pre-flight gate. Return true to proceed, false to abort. The optional second return is shown as a toast.

Validate = function(props)
if not (props.FirstSelected and props.FirstSelected:IsA("BasePart")) then
return false, "Select a part first"
end
return true
end,

If Validate returns false:

  • Run does not fire.
  • No ChangeHistoryService recording is opened (so no empty undo step).
  • OnError does not fire (this isn't an error, it's a gate).
  • If a string was returned, it shows as a brief toast.

If Validate itself throws, the abort still happens (fail-closed) and the throw is logged via warn. A broken validator should never silently let Run proceed.

Use Validate instead of an early return props:SendNotification(...) inside Run. It's declarative, keeps the Run body focused on the work, and makes the precondition obvious to anyone reading the module.

OnExecuted

Fires after Run returns successfully.

OnExecuted = function(props, arguments, result)
print("Just finished, args were:", arguments)
end,
ParameterTypeWhat it is
propsPropsSame props table that was passed to Run.
arguments{[string]: any}Same as props.Arguments, included for ergonomics.
resultanyWhatever Run returned. nil if Run had no return.

Triggers regardless of how Run was invoked:

  • The user clicked Execute in the args panel.
  • Another command called props:RunCommand("YourCommand", args).

It does not fire when other commands run. There's no global "after any command" hook. If you want to do something after running a sibling command, just put the code after the RunCommand call — RunCommand is synchronous, the next line doesn't execute until the inner command's Run (and its OnExecuted) have both finished:

Run = function(props)
props:RunCommand("AnchorAll")
print("AnchorAll done") -- runs after AnchorAll's OnExecuted
end

By the time OnExecuted fires the ChangeHistoryService recording is already committed. Anything you do inside OnExecuted is its own undo step. If your post-step needs to be inside the same undo, do it inside Run.

OnError

Symmetric pair with OnExecuted for the failure case. Fires when Run throws.

OnError = function(props, arguments, err)
props:SendNotification(string.format("RecolorParts failed: %s", err))
end,
ParameterTypeWhat it is
propsPropsSame props that was passed to the failed Run.
arguments{[string]: any}Args snapshot at the time of execute.
errstringThe error message Run threw.

The plugin still emits its own warn for the underlying error, so the Output console always shows the failure. OnError is for command-side reaction: a custom toast, rolling back partial state, telemetry, retry. The ChangeHistoryService recording is committed regardless of success or failure (Roblox's recording API doesn't have a discard path); any mutations Run made before throwing are part of the undo step.

OnSelected / OnDeselected

Pair of hooks for the command's selection lifecycle. Useful for live previews — apply a highlight on select, remove it on deselect.

OnSelected = function(props)
if props.FirstSelected then
local hl = Instance.new("Highlight")
hl.Name = "__RecolorPreview"
hl.Adornee = props.FirstSelected
hl.Parent = props.FirstSelected
end
end,

OnDeselected = function(props)
for _, inst in workspace:GetDescendants() do
if inst.Name == "__RecolorPreview" then
inst:Destroy()
end
end
end,

OnSelected fires when:

  • The user clicks the command in the sidebar.
  • A different command was active and the user switched (the previous command's OnDeselected fires first, then the new command's OnSelected).

OnDeselected fires when:

  • The user picks a different command from the sidebar.
  • The user deletes the active command.
  • The user closes the widget (toggles it off via the toolbar button).

Both hooks receive the same props shape as Run. props.Arguments reflects the latest persisted argument values; props.Selected is the Studio selection snapshot at the moment the hook fires. The hook is called synchronously — don't yield. If you need long-running work, use task.spawn and clean it up in OnDeselected.

A few details that surprise people:

  • No automatic re-selection on widget reopen. Closing the widget calls OnDeselected. Reopening doesn't auto-fire OnSelected; the user has to click a command. Plan your cleanup so the world is in a sane state without an OnSelected to "rebuild" anything.
  • OnDeselected runs against a freshly-loaded module. If the user edited the script between select and deselect, OnDeselected runs the latest source — not whatever code was active when OnSelected ran. Don't rely on closure state shared across the pair; persist anything you need (Instance attributes, props.Configuration).
  • Module deletion is a deselect. If the active command's ModuleScript is destroyed externally (Explorer drag-out, scripted removal), the plugin runs OnDeselected if the script is still resolvable. If the script is already gone the hook silently no-ops — there's no module to load to call the hook on.

Errors inside hooks

All five hooks are wrapped in pcall. An error inside any hook is logged via warn and discarded. Hooks are best-effort, not load-bearing. If your hook must succeed, audit it the same way you'd audit Run — log + recover, don't assume.

A worked example

A "duplicate selection" command that uses every hook:

return {
Arguments = {
Count = { Type = "number", Default = 3, Min = 1, Max = 50 },
},

Validate = function(props)
local source = props.FirstSelected
if not (source and source:IsA("BasePart")) then
return false, "Select a part first"
end
return true
end,

Run = function(props, arguments)
local source = props.FirstSelected
local copies = {}
for i = 1, arguments.Count do
local copy = source:Clone()
copy.Position = source.Position + Vector3.new(0, 4 * i, 0)
copy.Parent = source.Parent
table.insert(copies, copy)
end
return copies
end,

OnExecuted = function(props, args, copies)
props:SetSelection(copies)
props:SendNotification(string.format("Created %d copies", #copies))
end,

OnError = function(props, args, err)
props:SendNotification("Duplicate failed, see Output for details")
end,

OnSelected = function(props)
print("Duplicate command armed; pick a part and Execute")
end,

OnDeselected = function(props)
print("Duplicate command stowed")
end,
}

Validate keeps Run short and readable. OnExecuted does the "after" work that doesn't belong in the undo recording. OnError gives the user a friendlier message than the bare warn. OnSelected / OnDeselected are toy here, but they're where you'd attach the live-preview highlight described above.