Dynamic Rendering
Build Flutter UIs from JSON at runtime. WDynamic turns a Map structure into a live widget tree with action handling and form state management.
- Introduction
- How It Works
- JSON Schema
- Action System
- Form State Management
- Security & Whitelisting
- Custom Widget Builders
- Custom Icons
- Error Handling
- Related Documentation
Introduction
Dynamic rendering, also known as Server-Driven UI, allows your backend to control the structure and behavior of your Flutter interface at runtime. Instead of hard-coding widget trees in Dart, you define them in JSON and let WDynamic convert them into live widgets.
This approach unlocks powerful capabilities:
- CMS-driven layouts - Non-technical teams can author UI configurations through admin panels.
- A/B testing - Experiment with different layouts without rebuilding your app.
- Remote configuration - Update UI elements instantly via API responses.
- White-label apps - Customize entire interfaces per client from a single codebase.
Traditional approach:
// Hard-coded widget tree
WDiv(
className: 'flex gap-4 p-6',
children: [
WButton(onTap: handleSubmit, child: WText('Submit')),
],
)
Dynamic approach:
// JSON-driven widget tree
WDynamic(
json: {
'type': 'WDiv',
'props': {'className': 'flex gap-4 p-6'},
'children': [
{
'type': 'WButton',
'props': {
'onTap': {'action': 'handleSubmit'},
},
'children': [
{'type': 'WText', 'props': {'text': 'Submit'}}
],
},
],
},
actions: {'handleSubmit': (args, state) => print('Form: ${state.getAll()}')},
)
How It Works
WDynamic follows a simple pipeline:
JSON Map → WDynamicRenderer → Widget Tree
The renderer parses your JSON configuration, validates widget types against a security whitelist, instantiates the corresponding Flutter widgets, and wires up action callbacks. All Wind utilities (className, responsive prefixes, state modifiers) work exactly as they do in statically-defined widgets.
Here's a basic example that renders a styled container with text:
WDynamic(
json: {
'type': 'WDiv',
'props': {
'className': 'p-6 bg-white dark:bg-slate-800 rounded-xl shadow-sm',
},
'children': [
{
'type': 'WText',
'props': {
'text': 'Hello from JSON!',
'className': 'text-xl font-bold text-gray-800 dark:text-white',
},
},
],
},
)
The output is identical to writing the equivalent WDiv and WText widgets directly in Dart.
JSON Schema
Every widget definition follows a consistent three-key structure:
{
"type": "WidgetType",
"props": { /* widget properties */ },
"children": [ /* nested widgets */ ]
}
type
The widget class name as a string. Must be present in the default whitelist or registered via custom builders.
{'type': 'WDiv'}
{'type': 'WButton'}
{'type': 'Column'} // Flutter core widgets also supported
props
A map of properties specific to the widget type. Common properties include:
| Property | Description | Example |
|---|---|---|
className |
Wind utility classes | "flex gap-4 p-6" |
text |
Text content (WText) | "Submit Form" |
placeholder |
Input placeholder (WInput) | "Enter email..." |
id |
State tracking identifier | "username" |
onTap, onChange, etc. |
Action bindings | {"action": "submit", "args": {...}} |
{
'type': 'WInput',
'props': {
'id': 'email',
'placeholder': '[email protected]',
'className': 'border border-gray-300 rounded-lg p-3',
'onChange': {'action': 'validateEmail'},
},
}
children
An array of nested widget definitions. Optional for widgets like WText or WIcon.
{
'type': 'WDiv',
'props': {'className': 'flex flex-col gap-2'},
'children': [
{'type': 'WText', 'props': {'text': 'First'}},
{'type': 'WText', 'props': {'text': 'Second'}},
],
}
Action System
Interactive widgets (buttons, inputs, checkboxes) trigger actions via props like onTap, onLongPress, onDoubleTap, onChange, and onChanged. Each action prop references a named handler registered in the actions map.
Supported action props:
onTap- Single tap/click (WButton, WAnchor)onLongPress- Long press gesture (WButton, WAnchor)onDoubleTap- Double tap/click (WButton, WAnchor)onChange- Value change (WInput, WCheckbox, WSelect, WDatePicker)onChanged- Alias for onChange
JSON syntax:
{
'onTap': {
'action': 'actionName',
'args': {'key': 'value'}
}
}
Handler signature:
Action handlers support two signatures. Wind automatically detects which one you provide:
// Simple signature (args only)
(Map args) {
print('Action triggered with ${args['key']}');
}
// Stateful signature (args + state)
(Map args, WDynamicState state) {
final username = state.get('username');
print('User $username clicked ${args['button']}');
}
Complete example:
WDynamic(
json: {
'type': 'WButton',
'props': {
'className': 'bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded',
'onTap': {'action': 'submitForm', 'args': {'formId': 'login'}},
},
'children': [
{'type': 'WText', 'props': {'text': 'Login'}}
],
},
actions: {
'submitForm': (Map args, WDynamicState state) {
final formId = args['formId'];
final email = state.get('email');
final password = state.get('password');
print('Submitting $formId: email=$email');
},
},
)
[!NOTE] If an action name is not found in the
actionsmap, WDynamic logs a warning and continues silently. This prevents crashes from misconfigured JSON.
Form State Management
Widgets with an id prop automatically register their values in a shared state store. This eliminates manual state wiring for form fields.
Auto-tracked widgets:
WInput- Tracks text valueWCheckbox- Tracks boolean checked stateWSelect- Tracks selected valueWDatePicker- Tracks DateTime value
Accessing state from actions:
WDynamic(
json: {
'type': 'WDiv',
'props': {'className': 'flex flex-col gap-4'},
'children': [
{
'type': 'WInput',
'props': {
'id': 'username', // Auto-tracked
'placeholder': 'Enter name...',
},
},
{
'type': 'WButton',
'props': {
'onTap': {'action': 'greet'},
},
'children': [
{'type': 'WText', 'props': {'text': 'Greet'}}
],
},
],
},
actions: {
'greet': (args, state) {
final name = state.get('username') ?? 'Guest';
print('Hello, $name!');
},
},
)
External state access with WDynamicController:
The controller API allows you to read/write form values from outside the WDynamic widget:
final controller = WDynamicController();
// Pre-fill values
controller.setValue('email', '[email protected]');
controller.setValue('newsletter', true);
// Pass to WDynamic
WDynamic(
json: myFormJson,
controller: controller,
actions: myActions,
)
// Read values externally
final email = controller.getValue('email');
final allValues = controller.getAll(); // {'email': '...', 'newsletter': true}
// Listen for changes
final dispose = controller.addListener('email', (value) {
print('Email changed: $value');
});
// Clean up
controller.dispose();
State API reference:
| Method | Description |
|---|---|
getValue(id) |
Get a single value by id |
setValue(id, value) |
Set a value (triggers listeners) |
getAll() |
Get all values as a map |
reset() |
Clear all values |
addListener(id, callback) |
Listen for changes to a specific id |
Security & Whitelisting
Because JSON can come from untrusted sources (user input, third-party APIs), WDynamic uses a whitelist-only security model. Only explicitly allowed widget types can render.
Default allowed widgets:
Wind widgets:
WDiv,WText,WButton,WImage,WIcon,WAnchor,WInput,WCheckbox,WSvg,WSelect,WPopover,WDatePicker,WSpacer
Flutter core widgets:
Column,Row,Center,SizedBox,Expanded,Container,Wrap,Stack,Positioned,Padding,Align,Opacity,AspectRatio,FittedBox,ClipRRect,Spacer
Restricting widgets:
Use the denyWidgets parameter to remove specific types from the whitelist:
WDynamic(
json: untrustedJson,
denyWidgets: {'WButton', 'Stack'}, // Disable buttons and stacks
)
Any attempt to render a denied widget will trigger the onUnknownWidget callback or render an empty container.
Why whitelist-only?
Arbitrary widget instantiation could expose dangerous APIs (file system access, network calls, etc.). The whitelist ensures that only safe, layout-focused widgets can be dynamically rendered.
Custom Widget Builders
Extend WDynamic with your own widget types using the builders parameter:
WDynamic(
json: {
'type': 'ProfileCard',
'props': {
'name': 'Alice',
'role': 'Engineer',
'avatar': 'https://example.com/avatar.png',
},
},
builders: {
'ProfileCard': (Map props, List children) {
return WDiv(
className: 'flex items-center gap-4 p-4 bg-white rounded-lg shadow',
children: [
WImage(props['avatar'], className: 'w-12 h-12 rounded-full'),
WDiv(
className: 'flex flex-col',
children: [
WText(props['name'] ?? '', className: 'font-bold'),
WText(props['role'] ?? '', className: 'text-sm text-gray-600'),
],
),
],
);
},
},
)
Builder signature:
typedef WWidgetBuilder = Widget Function(
Map props,
List children,
);
Custom builders receive the parsed props map and any resolved children widgets. You can extract typed values, apply conditional logic, or compose complex layouts.
[!WARNING] Custom builders bypass the security whitelist. Only register builders for widgets you trust. Never pass user-provided builder functions.
Custom Icons
For WIcon widgets in your JSON, you can provide custom icon mappings without building a full custom widget builder. Use the customIcons prop to map string names to IconData:
WDynamic(
json: {
'type': 'WIcon',
'props': {'icon': 'myCustomIcon'},
},
customIcons: {
'myCustomIcon': Icons.star,
'myOtherIcon': Icons.favorite,
},
)
This is a lightweight alternative to custom builders when you only need to extend the icon library. See WDynamic — Custom Icons for complete configuration details.
Error Handling
WDynamic provides two callbacks for handling rendering failures:
onUnknownWidget - Called when a widget type is not in the whitelist:
WDynamic(
json: {'type': 'UnsafeWidget', 'props': {}},
onUnknownWidget: (String type, Map props) {
return WText(
'Widget "$type" not allowed',
className: 'text-red-500 p-2 bg-red-50 rounded',
);
},
)
onError - Called when a widget build throws an exception:
WDynamic(
json: myJson,
onError: (String type, Object error) {
return WDiv(
className: 'p-4 bg-red-100 border border-red-300 rounded',
child: WText('Error rendering $type: $error'),
);
},
)
Both callbacks are optional. If not provided, WDynamic renders an empty SizedBox.shrink() for failures.
Maximum depth protection:
To prevent infinite recursion or stack overflows from deeply nested JSON, WDynamic enforces a maximum depth limit (default: 50 levels):
WDynamic(
json: deeplyNestedJson,
maxDepth: 100, // Increase if needed
)
Related Documentation
- WDynamic Widget - Full API reference
- State Management - Interactive states and WAnchor
- Utility-First Fundamentals - Wind className syntax
- WButton - Action-driven button widget
- WInput - Form input with state tracking