Formly compiles each value in expressions at run time. It builds a function from your string:
Function("model", "formState", "field", `return ${expression};`);
Your expression goes in the return position. A return statement accepts an expression. It does not accept a statement. Therefore const, if, for and early returns cause a syntax error.
An IIFE (immediately invoked function expression) gives you a function body. Statements are legal in a function body.
(() => {
/* Write statements here. */
})();
An IIFE gives you statements. If you do not need a statement, do not use an IIFE.
Use a plain expression for a comparison, for boolean logic, for one ternary, or for one array method.
"expressions": {
"hide": "model?.DOCUMENT_TYPE !== 'PASSPORT'"
}
"expressions": {
"props.readonly": "formState?.APPLICATION_STATE === 'PENDING_RESUBMISSION'"
}
"expressions": {
"hide": "!(field?.parent?.parent?.parent?.parent?.model?.SECTION_OWNERS?.BLOCK_OWNERS?.OWNER_DETAILS ?? []).some((row) => row?.IS_PRIMARY === 'Yes')"
}
Use an IIFE only for one of these conditions:
If no condition applies, remove the IIFE.
Use an arrow function. Call it immediately. Do not put a semicolon at the end.
(() => {
return true;
})();
The older form (function () { … })() also operates correctly, and some forms contain it. Do not write new expressions with this form.
Do not use this in an expression. The two forms give this a different value. You do not need this, because field is already a parameter.
An expression has three identifiers. It has no other data, but it can use the JavaScript global objects.
| Identifier | Contents |
|---|---|
model |
The data for the current scope. This is not the data of the full form. |
field |
The Formly configuration of this field, with its parent chain. |
formState |
State that all the fields of the form share. See Form State. |
model gives the data of the nearest parent that has a key. A field in SECTION_FOO > BLOCK_BAR reads only the keys of its own block. To read a key in a different section or block, go up the parent chain. Form Rules gives the depth rules. The same rules apply in an IIFE.
An expression that throws an error stops the field. Formly shows the message [Formly Error] [Expression "hide"] …. Formly has no fallback value and no error boundary.
Optional chaining is not enough. undefined?.filter gives undefined, but the next call in the chain throws an error. Also, the model of a repeater is undefined until the first row shows.
For an array, make a safe value before you use it:
const rows = Array.isArray(raw_rows) ? raw_rows : [];
For a single value, return early when the value is not available:
if (!birth_date) return true;
Select the early return value with care. For hide, the value true keeps the field hidden until the user gives the input.
Use snake_case for the local variables in an IIFE.
Use UPPER_SNAKE_CASE only for form keys, in the same form as the JSON file. Do not give a local variable the name of the key that it holds.
// The key is UPPER_SNAKE_CASE. The local variable is not.
const shareholder_details =
field?.parent?.parent?.parent?.parent?.model?.SECTION_CAPITAL?.BLOCK_SHAREHOLDERS
?.SHAREHOLDER_DETAILS;
The result is clear: UPPER_SNAKE_CASE in an expression is always a form key. All other names are local variables.
An expression calculates a value. It does no other task.
model, field, or formState. The advanced pattern returns a value. It does not assign one.new Date() for a comparison. The first example shows this. But do not write a value from new Date() into the model. Such a value changes at each pass and causes continuous updates.Formly calculates each expression of each field at each change-detection pass.
Formly compares the new value with the last value with JSON.stringify. Therefore you can return a new array or a new object at each pass. When the two values are equal, Formly does nothing. But the value must be compatible with JSON. A structure that refers to itself throws an error.
Keep the body small. A sort, a deep copy, or a scan of a large repeater at each keystroke makes the form slow for the user.
Write the IIFE on one line in the JSON string. All the expressions in production use one line. Escaped newline characters in JSON are more difficult to read than one long line.
This limit is useful. Each example below shows the code two times: first with indentation, then as the one line that you commit. If the one-line version is too difficult to read, the IIFE is too large. Divide the logic between fields, or move it out of the form.
An expression can return any type. The host decides how it uses the value. Match the type to the host:
| Host | Type to return |
|---|---|
hide |
Boolean. true hides the field. |
props.hideField |
Boolean. true hides the field but keeps the value. |
props.readonly |
Boolean. |
props.label |
Text. |
props.hint |
Text. Return '' when there is no hint. |
props.max |
Number. |
props.options |
An array of objects. Each object has a value and a label. |
model.SOME_KEY |
Any value that is compatible with JSON. See below. |
Return one type from all the paths of your expression. A prop that gets text at one pass and undefined at the next pass gives a defect that is difficult to find.
This block shows only when the applicant is less than 16 years old. The block stays hidden until the user gives a birth date.
(() => {
const birth_date = model?.APPLICANT_BIRTH_DATE;
if (!birth_date) return true;
const [day_str, month_str, year_str] = birth_date.split("/");
const birth = new Date(Number(year_str), Number(month_str) - 1, Number(day_str));
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const month_delta = today.getMonth() - birth.getMonth();
if (month_delta < 0 || (month_delta === 0 && today.getDate() < birth.getDate())) age--;
return age >= 16;
})();
"expressions": {
"hide": "(() => { const birth_date = model?.APPLICANT_BIRTH_DATE; if (!birth_date) return true; const [day_str, month_str, year_str] = birth_date.split('/'); const birth = new Date(Number(year_str), Number(month_str) - 1, Number(day_str)); const today = new Date(); let age = today.getFullYear() - birth.getFullYear(); const month_delta = today.getMonth() - birth.getMonth(); if (month_delta < 0 || (month_delta === 0 && today.getDate() < birth.getDate())) age--; return age >= 16; })()"
}
An IIFE is necessary here. The code uses birth and today more than one time. The code also returns early when the birth date is not available.
Remove the option "Spouse" when the applicant is the spouse, or when the event is a marriage.
Always send each label through formState.getTranslation(). The portal shows the option labels in the language of the user. A label that you return directly stays in one language.
(() => {
const all_options = [
{ value: "SPOUSE", label: "Spouse" },
{ value: "PARENT", label: "Parent" },
{ value: "CHILD", label: "Child" },
{ value: "SIBLING", label: "Sibling" },
];
const excludes_spouse =
model?.RELATIONSHIP_TO_APPLICANT === "SPOUSE" || model?.REASON_FOR_REQUEST === "MARRIAGE";
const selected_options = excludes_spouse
? all_options.filter((option) => option.value !== "SPOUSE")
: all_options;
return selected_options.map((option) => ({
value: option.value,
label: formState?.getTranslation(option.label),
}));
})();
"expressions": {
"props.options": "(() => { const all_options = [{ value: 'SPOUSE', label: 'Spouse' }, { value: 'PARENT', label: 'Parent' }, { value: 'CHILD', label: 'Child' }, { value: 'SIBLING', label: 'Sibling' }]; const excludes_spouse = model?.RELATIONSHIP_TO_APPLICANT === 'SPOUSE' || model?.REASON_FOR_REQUEST === 'MARRIAGE'; const selected_options = excludes_spouse ? all_options.filter((option) => option.value !== 'SPOUSE') : all_options; return selected_options.map((option) => ({ value: option.value, label: formState?.getTranslation(option.label) })); })()"
}
Translate the label. Do not translate the value. The value goes to the workflow and to the integrations, therefore it must stay the same in all languages.
This expression returns a new array at each pass. This is correct. Formly compares the arrays with JSON.stringify, and it does nothing when the options do not change.
In the JSON string, use single quotes for the JavaScript text values. The JSON string uses the double quotes.
Show a hint only when the shareholder rows show that a beneficial owner is missing.
(() => {
const raw_rows =
field?.parent?.parent?.parent?.parent?.model?.SECTION_CAPITAL?.BLOCK_SHAREHOLDERS
?.SHAREHOLDER_DETAILS;
const shareholders = Array.isArray(raw_rows) ? raw_rows : [];
const declared_owners = shareholders.filter((row) => row?.IS_BENEFICIAL_OWNER === "Yes");
if (declared_owners.length > 0) return "";
const has_major_holder = shareholders.some((row) => Number(row?.OWNERSHIP_PERCENTAGE) > 25);
return has_major_holder ? formState?.getTranslation("beneficialOwnerMissingHint") : "";
})();
"expressions": {
"props.hint": "(() => { const raw_rows = field?.parent?.parent?.parent?.parent?.model?.SECTION_CAPITAL?.BLOCK_SHAREHOLDERS?.SHAREHOLDER_DETAILS; const shareholders = Array.isArray(raw_rows) ? raw_rows : []; const declared_owners = shareholders.filter((row) => row?.IS_BENEFICIAL_OWNER === 'Yes'); if (declared_owners.length > 0) return ''; const has_major_holder = shareholders.some((row) => Number(row?.OWNERSHIP_PERCENTAGE) > 25); return has_major_holder ? formState?.getTranslation('beneficialOwnerMissingHint') : ''; })()"
}
Number(row?.OWNERSHIP_PERCENTAGE) is necessary. Form values are text. The comparison "30" > 25 gives the correct result, but the comparison "9" > 25 gives an incorrect result. Change the type to a number before you compare.
Use this pattern only when no other solution is possible.
An expression with the key model.SOME_KEY writes to the model. It does not read the model. Formly assigns the return value to that key. The key becomes part of the model, therefore the portal sends it with the application data.
The platform already makes the application data flat when the user submits the form. Do not use this pattern to change the shape of data that a field holds. Use it only for a value that no field holds and that no field can calculate, for example a total across the rows of a repeater.
This expression gives the sum of the declared ownership percentages. The workflow rules compare this total with 100.
(() => {
const raw_rows =
field?.parent?.parent?.parent?.parent?.model?.SECTION_CAPITAL?.BLOCK_SHAREHOLDERS
?.SHAREHOLDER_DETAILS;
const shareholders = Array.isArray(raw_rows) ? raw_rows : [];
const total = shareholders.reduce((sum, row) => {
const percentage = Number(row?.OWNERSHIP_PERCENTAGE);
return Number.isFinite(percentage) ? sum + percentage : sum;
}, 0);
return Math.round(total * 100) / 100;
})();
"expressions": {
"model.TOTAL_DECLARED_OWNERSHIP": "(() => { const raw_rows = field?.parent?.parent?.parent?.parent?.model?.SECTION_CAPITAL?.BLOCK_SHAREHOLDERS?.SHAREHOLDER_DETAILS; const shareholders = Array.isArray(raw_rows) ? raw_rows : []; const total = shareholders.reduce((sum, row) => { const percentage = Number(row?.OWNERSHIP_PERCENTAGE); return Number.isFinite(percentage) ? sum + percentage : sum; }, 0); return Math.round(total * 100) / 100; })()"
}
Number.isFinite removes the rows that hold no number. Without this test, one empty row gives the result NaN, and NaN is not compatible with JSON. Math.round removes the small errors of decimal addition, which stop the JSON.stringify comparison from finding two equal values.
Obey all of these rules:
JSON.stringify, then the portal sends it. A structure that refers to itself throws an error. A member with the value undefined is lost.Form validation does not see a derived key. The field validators do not see it. A person who reads the form for the first time does not see it. Before you use this pattern, examine the system that receives the data. When that system can calculate the value, let it do this task.
Older pages kept in this wiki for this topic: