Skip to main content
Mythic Framework uses React for all user interfaces, rendered inside FiveM’s NUI layer (a Chromium-based browser). Client Lua scripts communicate with React UIs through NUI messages and callbacks.

UI Architecture

Technology Stack

Mythic resources use two UI stacks depending on age:

Legacy UIs

React 17 + Redux + ThunkUsed by: HUD, Inventory, MDT, Laptop, most existing resources

Modern UIs

React 18 + Zustand + TypeScriptUsed by: New Phone UI (ui-new/), newer resources
Common across both:
  • Material-UI v5 — Component library
  • Emotion — CSS-in-JS styling
  • Webpack 5 — Module bundler

UI Resources

mythic-hud

Health, armor, status bars, vehicle info, minimap, notifications

mythic-phone

Smartphone with contacts, messages, calls, email, camera, apps

mythic-inventory

Drag-and-drop inventory, tooltips, crafting, shops

mythic-mdt

Police MDT — warrants, person/vehicle lookup, dispatch, reports

mythic-laptop

Laptop interface with email, documents, browser

mythic-characters

Character creation, appearance customization, selection

mythic-menu

Context menus with nested submenus and keyboard navigation

mythic-chat

In-game chat with commands

Client-UI Communication

Lua to React (SendNUIMessage)

-- client/main.lua

-- Open UI with data
SendNUIMessage({
    type = 'OPEN_INVENTORY',
    data = {
        inventory = inventoryData,
        maxSlots = 50,
        maxWeight = 100
    }
})

-- Update specific data
SendNUIMessage({
    type = 'UPDATE_MONEY',
    data = { cash = 5000, bank = 25000 }
})

-- Close UI
SendNUIMessage({ type = 'CLOSE_INVENTORY' })
React side — listening for messages:
// Legacy (Redux pattern)
useEffect(() => {
    const handleMessage = (event) => {
        const { type, data } = event.data;
        switch (type) {
            case 'OPEN_INVENTORY':
                setInventory(data.inventory);
                setVisible(true);
                break;
            case 'CLOSE_INVENTORY':
                setVisible(false);
                break;
        }
    };
    window.addEventListener('message', handleMessage);
    return () => window.removeEventListener('message', handleMessage);
}, []);

React to Lua (NUI Callbacks)

// ui/src/utils/fetchNui.js
export const fetchNui = async (eventName, data = {}) => {
    const resourceName = window.GetParentResourceName
        ? window.GetParentResourceName()
        : 'mythic-inventory';

    const response = await fetch(`https://${resourceName}/${eventName}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
    });

    return await response.json();
};

// Usage in a component
const handleUseItem = (slot) => {
    fetchNui('useItem', { slot }).then((response) => {
        if (response.success) {
            console.log('Item used');
        }
    });
};
Lua side — handling NUI callbacks:
-- client/ui.lua
RegisterNUICallback('useItem', function(data, cb)
    local slot = data.slot
    TriggerServerEvent('mythic-inventory:server:UseItem', slot)
    cb({ success = true })
end)

RegisterNUICallback('closeInventory', function(data, cb)
    SetNuiFocus(false, false)
    cb('ok')
end)

Communication Flow

Custom Hooks

useNuiEvent

Simplify NUI message handling:
import { useEffect } from 'react';

export const useNuiEvent = (action, handler) => {
    useEffect(() => {
        const handleMessage = (event) => {
            const { type, data } = event.data;
            if (type === action) {
                handler(data);
            }
        };
        window.addEventListener('message', handleMessage);
        return () => window.removeEventListener('message', handleMessage);
    }, [action, handler]);
};

// Usage
function InventoryApp() {
    const [visible, setVisible] = useState(false);

    useNuiEvent('OPEN_INVENTORY', () => setVisible(true));
    useNuiEvent('CLOSE_INVENTORY', () => setVisible(false));
}

State Management

Redux (Legacy UIs)

// Store setup
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

const store = createStore(rootReducer, applyMiddleware(thunk));

// Reducer
const initialState = { items: [], visible: false };

export default function inventoryReducer(state = initialState, action) {
    switch (action.type) {
        case 'SET_INVENTORY':
            return { ...state, items: action.payload.items };
        case 'TOGGLE_VISIBLE':
            return { ...state, visible: !state.visible };
        default:
            return state;
    }
}

// Component
function Inventory() {
    const items = useSelector(state => state.inventory.items);
    const dispatch = useDispatch();

    const handleUseItem = (slot) => dispatch(useItem(slot));

    return (
        <div className="inventory">
            {items.map((item, i) => (
                <ItemSlot key={i} item={item} onClick={() => handleUseItem(i)} />
            ))}
        </div>
    );
}

Zustand (Modern UIs)

// Store definition
import { create } from 'zustand';

interface PhoneState {
    visible: boolean;
    notifications: Notification[];
    setVisible: (v: boolean) => void;
    addNotification: (n: Notification) => void;
}

export const usePhoneStore = create<PhoneState>((set) => ({
    visible: false,
    notifications: [],
    setVisible: (visible) => set({ visible }),
    addNotification: (n) => set((s) => ({
        notifications: [...s.notifications, n]
    })),
}));

// Component usage
function PhoneApp() {
    const visible = usePhoneStore((s) => s.visible);
    const notifications = usePhoneStore((s) => s.notifications);

    if (!visible) return null;
    return <div>...</div>;
}

Best Practices

Always manage NUI focus properly in Lua:
-- Open UI: enable cursor and keyboard input
SendNUIMessage({ type = 'OPEN_UI' })
SetNuiFocus(true, true)

-- Close UI: disable cursor and input
RegisterNUICallback('close', function(data, cb)
    SetNuiFocus(false, false)
    cb('ok')
end)
Always handle ESC to close UI:
useEffect(() => {
    const handleEscape = (e) => {
        if (e.key === 'Escape' && visible) {
            fetchNui('close');
            setVisible(false);
        }
    };
    window.addEventListener('keydown', handleEscape);
    return () => window.removeEventListener('keydown', handleEscape);
}, [visible]);
Don’t render hidden UIs — return null:
function MyUI() {
    const [visible, setVisible] = useState(false);

    useNuiEvent('OPEN_UI', () => setVisible(true));
    useNuiEvent('CLOSE_UI', () => setVisible(false));

    if (!visible) return null;
    return <div>...</div>;
}
Optimize React rendering:
// Memoize expensive components
const ItemSlot = React.memo(({ item, onClick }) => {
    return <div onClick={onClick}>{item.label}</div>;
});

// Cache expensive calculations
const totalWeight = useMemo(() => {
    return items.reduce((sum, item) => sum + (item.weight * item.count), 0);
}, [items]);

Building UIs

# Install dependencies
npm install

# Development mode with hot reload
npm run dev

# Production build (outputs to dist/)
npm run build

# Watch mode (rebuilds on file changes)
npm run watch
Built UI files go into dist/ which is referenced by ui_page in fxmanifest.lua. Always run npm run build before testing in-game.

Next Steps

Resource Structure

How UI fits into resource organization

Component System

Server/client components that power UIs

Event System

Events that trigger UI updates

HUD API

HUD component API reference