Patching Components
Change what Discord's components render.
Patch a component's render, usually with after, and edit the tree it returns. Your changes join Discord's real tree, so they keep its context and survive re-renders. Builds on Patching Functions.
Quick Start
Pass the module holding the component and its key, then return the tree you want.
import { metro, patcher } from '@unbound-app/api';
export function startToastPatch() {
const Toasts = metro.findByProps('ToastContainer');
if (!Toasts) {
logger.warn('Toast patch skipped: Discord moved ToastContainer.');
return;
}
patcher.react.after(Toasts, 'ToastContainer', ({ props, result }) => {
// wrap, replace, or return the original tree
return result;
});
}The patch waits for a lazy lookup to load, never loading it early.
Reference
Each call returns a function that removes the patch, and takes { once: true } as its last argument.
| Call | Runs | Callback returns |
|---|---|---|
patcher.react.before(target, callback, options?)patcher.react.before(parent, key, callback, options?) | Before the render | New props, or nothing |
patcher.react.after(target, callback, options?)patcher.react.after(parent, key, callback, options?) | After the render | A tree to replace result, or nothing |
patcher.react.instead(target, callback, options?)patcher.react.instead(parent, key, callback, options?) | In place of it | The tree to render |
| Field | Holds |
|---|---|
props | This render's props. For a class, this.props |
result | What rendered, after earlier patches. after only |
original | Renders the unpatched component. New props apply to function components only |
this | The class instance, or undefined for a function component |
Targets
| Component | Pass | patcher.react patches |
|---|---|---|
React.memo | Itself, or parent and key | .type, unwrapping a forwardRef or class inside |
React.forwardRef | Itself, or parent and key | .render |
| Class | Itself, or parent and key | prototype.render |
| Plain function | Parent and key only. Alone, it throws | The key that holds it |
Tree Helpers
| Function | Returns |
|---|---|
utils.react.findComponent(tree, match) | First element match picks, or undefined |
utils.react.filterComponents(tree, match) | Every element match picks |
utils.findInTree(root, predicate, options?)utils.filterInTree(root, predicate, options?) | First match, or every match, in any object |
utils.react.findInTree(root, predicate, options?)utils.react.filterInTree(root, predicate, options?) | The same, through React element and fiber keys |
utils.react.useShallow(selector), utils.react.shallow(a, b) | A selector that skips shallow-equal re-renders, and its comparison |
match is a component name, a host type such as 'RCTView', or a function returning true for an element.
Full signatures: patcher and utils.react in the SDK reference.
Patch Kinds
Return a tree to replace result, or nothing to keep it.
import { metro, patcher } from '@unbound-app/api';
export function patchUserProfile() {
const Profile = metro.findByProps('UserProfile');
if (!Profile) {
logger.warn('Profile patch skipped: Discord moved UserProfile.');
return;
}
patcher.react.after(Profile, 'UserProfile', ({ props, result }) => {
logger.log('Rendering profile of', props.userId);
return result;
});
}Return new props to render with.
import { metro, patcher } from '@unbound-app/api';
export function patchUserProfile() {
const Profile = metro.findByProps('UserProfile');
if (!Profile) {
logger.warn('Compact profiles skipped: Discord moved UserProfile.');
return;
}
patcher.react.before(Profile, 'UserProfile', ({ props }) => {
return { ...props, compact: true };
});
}Classes render from this.props, so before can't replace them. See Class Components.
Renders your return value. Call original for Discord's tree.
import { metro, patcher } from '@unbound-app/api';
export function patchUserProfile() {
const Profile = metro.findByProps('UserProfile');
if (!Profile) {
logger.warn('Hiding bot profiles skipped: Discord moved UserProfile.');
return;
}
patcher.react.instead(Profile, 'UserProfile', ({ props, original }) => {
if (props.isBot) return null;
return original();
});
}Editing the Tree
Each element has type, props and props.children. findComponent returns the first match.
import { metro, patcher, utils } from '@unbound-app/api';
export function patchUserProfile() {
const Profile = metro.findByProps('UserProfile');
if (!Profile) {
logger.warn('Faded rows skipped: Discord moved UserProfile.');
return;
}
patcher.react.after(Profile, 'UserProfile', ({ result }) => {
const row = utils.react.findComponent(result, 'Row');
if (row) {
row.props.style = [row.props.style, { opacity: 0.5 }];
}
return result;
});
}- Guard every lookup. It returns
undefinedwhen Discord or another plugin changed the tree. - Add to
children; replacing them removes what other plugins added. - Patch the smallest component that works. A large, often-rendered one runs your code every render.
Child Components
A child created fresh on each parent render can't hold a lasting patch. Patch it inside the parent's patch with { once: true }, so it lasts one render.
import { metro, patcher, utils } from '@unbound-app/api';
export function patchUserProfile() {
const Profile = metro.findByProps('UserProfile');
if (!Profile) {
logger.warn('Badge patch skipped: Discord moved UserProfile.');
return;
}
patcher.react.after(Profile, 'UserProfile', ({ result }) => {
const child = utils.react.findComponent(result, 'ProfileBadges');
if (child && typeof child.type !== 'string') {
patcher.react.after(child.type, ({ result: badges }) => badges, { once: true });
}
return result;
});
}This needs child.type to be a memo, forwardRef or class. Patch a plain function where its module exports it.
Class Components
patcher.react patches only render. Check where another member lives first:
import { metro } from '@unbound-app/api';
const SomeClass = metro.findByName('SomeClass', { lazy: false });
if (SomeClass) {
Object.hasOwn(SomeClass.prototype, 'componentDidMount'); // true: a prototype method
Object.hasOwn(SomeClass, 'getDerivedStateFromProps'); // true: a static method
Object.hasOwn(SomeClass.prototype, 'onPress'); // false: a class field, on each instance
}Lifecycle methods and name() {} methods. Patch the prototype; mounted instances pick it up on their next call. componentDidMount patches only reach instances mounted later.
import { metro, patcher } from '@unbound-app/api';
export function patchSomeClass() {
const SomeClass = metro.findByName('SomeClass');
if (!SomeClass) {
logger.warn('Update log skipped: Discord moved SomeClass.');
return;
}
patcher.after(SomeClass.prototype, 'componentDidUpdate', ({ this: instance }) => {
logger.log('Updated with', instance.props);
});
}onPress = () => {} lives on each instance. Patch render with before to reach this, then patch the instance once. before means the render that reached the instance already passes the patched field down. The scope removes these patches too.
import { metro, patcher } from '@unbound-app/api';
const patched = new WeakSet<object>();
export function patchSomeClass() {
const SomeClass = metro.findByName('SomeClass');
if (!SomeClass) {
logger.warn('Press log skipped: Discord moved SomeClass.');
return;
}
patcher.react.before(SomeClass, ({ this: instance }) => {
if (patched.has(instance)) return;
patched.add(instance);
patcher.before(instance, 'onPress', () => {
logger.log('Pressed');
});
});
}Lives on the class itself. Patch it there.
import { metro, patcher } from '@unbound-app/api';
type SomeClassStatics = {
getDerivedStateFromProps(props: { compact?: boolean }, state: object): object;
};
// `metro.find` returns `any`, so the lookup takes the type you give it.
const SomeClass: SomeClassStatics = metro.find(metro.filters.byName('SomeClass'));
patcher.after(SomeClass, 'getDerivedStateFromProps', ({ args: [props], result }) => {
return { ...result, compact: props.compact ?? false };
});Patch the class where its module exports it. result is the new instance. Only instances created later, through that export, are seen.
import { metro, patcher } from '@unbound-app/api';
export function patchSomeClass() {
const SomeClassModule = metro.findByProps('SomeClass');
if (!SomeClassModule) {
logger.warn('Creation log skipped: Discord moved SomeClass.');
return;
}
patcher.after(SomeClassModule, 'SomeClass', ({ result: instance }) => {
logger.log('Created', instance);
});
}Changing Class Props
A class patch can't change props: before can't replace this.props, and original ignores new props. Patch the parent and swap the element's type for a function that renders the class with your props.
import { metro, patcher, utils } from '@unbound-app/api';
const ProfileRow = metro.findByName('ProfileRow');
// Declared once at the top level: a new function on each render would remount the row every time.
function CompactProfileRow(props: Record<string, unknown>) {
return ProfileRow ? <ProfileRow {...props} compact /> : null;
}
export function patchUserProfile() {
const Profile = metro.findByProps('UserProfile');
if (!Profile || !ProfileRow) {
logger.warn('Compact rows skipped: Discord moved UserProfile or ProfileRow.');
return;
}
patcher.react.after(Profile, 'UserProfile', ({ result }) => {
const row = utils.react.findComponent(result, 'ProfileRow');
if (row) row.type = CompactProfileRow;
return result;
});
}The same function each render keeps the row's state. The swap goes away with the patch.
Undoing a Component Patch
The scope removes your patches when the plugin stops, as does each patch's returned function. Mounted components re-render with Discord's render and keep their state, even if your patch called hooks.
stop still undoes changes made outside the render, such as a value written onto a Discord object. If stop also redraws, remove your patches first; see Lifecycle.
When a Patch Crashes
A throwing component patch can't crash Discord. What renders depends on what threw:
| What threw | What renders | Discord's UI remounts? | Counts toward removal? |
|---|---|---|---|
| An element you added beside Discord's | Nothing in its place, until it unmounts | No | No, only logged |
| Your callback | Discord's tree before your patch | Yes, if you changed its shape | Yes |
| Anything else in your tree | Discord's tree from that render, until unmount | Yes, below the component | Yes |
Aim for the first row: a focused chat input keeps its text and the keyboard stays open.
Added Elements
An element counts as added when it contains none of Discord's elements: beside Discord's children, pushed into them, or in a Fragment with them.
- Each is wrapped in a small error boundary keyed like the element. A parent reading children's props sees the boundary.
- An element with the same type as a Discord sibling stays unwrapped, since the parent may treat it as a list item.
- A tree with none of Discord's elements replaced Discord's; the other rows apply.
- A wrapper around Discord's elements, such as a provider around
result, isn't isolated; the last row applies.
Discord's elements are tracked in a WeakSet. Each patched render costs about 3 µs more.
Removal
- A single crash only logs the error, naming the component and the added element if one crashed.
- On a patch's first failure, the component renders nothing for one frame, then re-renders with a saved copy of Discord's tree. Patches that never failed don't keep a copy.
- After 3 crashes in a row, Unbound removes the patch and shows a toast. A clean commit resets the count. Every patch that built the crashing tree counts it.
- A removed patch stays off until the plugin starts again.
Later after patches get your tree as result, not the boundary. Text, null and trees that can't throw aren't wrapped.
Searching Any Tree
utils.findInTree walks any object depth-first and returns the first match. utils.filterInTree returns every match.
import { utils } from '@unbound-app/api';
declare const state: unknown;
const channel = utils.findInTree(state, (node) => node?.type === 'GUILD_TEXT');utils.react.findInTree and filterInTree walk only downward through props, children, type, child, sibling, memoizedProps, memoizedState and stateNode. Start from an element, a fiber, or a class instance's _reactInternals.
import { utils } from '@unbound-app/api';
declare const instance: { _reactInternals: unknown };
const fiber = utils.react.findInTree(
instance._reactInternals,
(node) => node?.memoizedProps?.channelId,
);| Option | Does |
|---|---|
walkable | utils.findInTree: the only keys to walk. utils.react: extra keys, such as 'return' to go up a fiber tree |
ignore | Keys never to walk |
maxDepth | Levels to walk below the start. Default 100 |
Searches skip values already seen, so cycles can't loop. See utils in the SDK reference.