Skip to main content

Conditional Rendering, Fallbacks, and Empty States

Robust ACF templates treat missing data as a first-class state, not an exceptional bug.

Learning Focus

You will design explicit fallback precedence rules, prevent empty wrapper rendering, and implement edge-case guards for partially configured ACF Pro structures. This is crucial because production content is rarely complete, especially during drafts, migrations, and cross-team publishing.

Concept Overview

Conditional rendering answers one question repeatedly: "Do we have enough data to render this block safely?" If yes, render normal output. If no, choose a fallback source or hide the block. The key is that this policy must be intentional and consistent across templates.

Fallback logic is not one-size-fits-all. Some values should inherit global defaults (phone numbers, legal footer text, default CTA label). Others should hide the section entirely when missing (testimonial quote, social icon cluster, optional trust badges). Mixing these policies without rules creates unpredictable UX.

Empty-state design protects both user experience and operational stability. When fields are absent, your template should avoid broken markup, avoid noisy warnings, and surface observability signals (logs/CLI checks) so teams can improve content completeness over time.

Core Idea

For each field-driven block, define one of three explicit outcomes: render value, render fallback, or hide block.

Why It Matters

ApproachWhat HappensImpact in Production
Define fallback precedence per componentRendering behavior stays predictable under partial dataBetter UX consistency and lower support load
Hide optional blocks when data is emptyPages avoid blank placeholders and broken wrappersCleaner design integrity during content drafts
Use option-level defaults for global valuesShared components stay functional when page fields are missingFaster publishing with fewer manual edits
Log fallback/empty-state events for auditTeams can measure content quality driftBetter editorial feedback loops
Assume fields are always populated (wrong pattern)Components break silently on draft or migrated pagesProduction defects, conversion drop, and emergency hotfixes

Reference Table

Term/APISignature/SyntaxPurposeKey Notes
get_field()`get_field(string $selector, intstring $post_id = false, bool $format_value = true): mixed`Retrieve primary and fallback values
Option fallback readget_field('field_name', 'option')Load global default valueACF Pro Required in options-page workflows
have_rows()`have_rows(string $selector, intstring $post_id = false): bool`Detect whether repeatable data exists
array_filter()array_filter(array $array, ?callable $callback = null): arrayRemove empty values before rendering clustersUseful for social links/icon sets
empty() / strict checksempty($value) / $value !== ''Determine render eligibilityPrefer explicit checks for critical fields
wp_kses_post()wp_kses_post(string $content): stringRender safe rich-text fallback contentUse for controlled WYSIWYG fields
error_log()error_log(string $message): boolTrack fallback/hide events in logsUseful for post-launch quality monitoring
wp evalwp eval '<php-code>'Verify fallback sources and effective valuesRequired for deterministic QA in CLI workflows

Practical Use Cases

Use Case 1 — Global CTA phone fallback (page-level → option-level → hide)

A service template should display a call CTA. Priority order is: page-specific number first, then global support number from options, and if both are missing, hide the CTA entirely.

  1. Read page-level phone field.
  2. Read option-level fallback phone.
  3. Resolve effective value using precedence.
  4. Render tel-link only when effective value exists.
  5. Log source used (page, option, or none) for observability.
wp-content/themes/clinic-pro/partials/contact-cta-fallback.php
<?php

declare(strict_types=1);

add_action('acf/init', function (): void {
if (!function_exists('acf_add_options_page')) {
return;
}

acf_add_options_page([
'page_title' => 'Global CTA Defaults',
'menu_title' => 'Global CTA',
'menu_slug' => 'clinic-global-cta-defaults',
'capability' => 'manage_options',
'redirect' => false,
]);
});

add_action('template_redirect', function (): void {
if (!is_page()) {
return;
}

$pagePhone = (string) get_field('page_contact_phone');
$globalPhone = (string) get_field('global_support_phone', 'option');

$effectivePhone = '';
$source = 'none';

if ($pagePhone !== '') {
$effectivePhone = $pagePhone;
$source = 'page';
} elseif ($globalPhone !== '') {
$effectivePhone = $globalPhone;
$source = 'option';
}

if ($effectivePhone === '') {
error_log('[cta-fallback] source=none page=' . get_the_ID());
return;
}

$telHref = preg_replace('/[^0-9+]/', '', $effectivePhone);

echo '<section class="contact-cta">';
echo '<a class="phone-link" href="tel:' . esc_attr((string) $telHref) . '">' . esc_html($effectivePhone) . '</a>';
echo '</section>';

error_log('[cta-fallback] source=' . $source . ' page=' . get_the_ID());
});
terminal: command
wp eval 'update_field("global_support_phone", "+65 6700 1234", "option"); echo get_field("global_support_phone", "option") . PHP_EOL;'
wp eval '$id=1360; update_field("page_contact_phone", "", $id); $page=(string)get_field("page_contact_phone",$id); $global=(string)get_field("global_support_phone","option"); echo ($page!=="" ? $page : $global) . PHP_EOL;'
wp eval '$id=1360; update_field("page_contact_phone", "+65 6999 0000", $id); $page=(string)get_field("page_contact_phone",$id); $global=(string)get_field("global_support_phone","option"); echo ($page!=="" ? $page : $global) . PHP_EOL;'
terminal: output
+65 6700 1234
+65 6700 1234
+65 6999 0000
ACF Pro Required

This fallback pattern uses option-context values from an options page workflow, which is commonly implemented with ACF Pro.

A content team can add optional social links in a footer component. Old posts often have empty rows or partially filled rows. You want to render only valid links and hide the entire social block when nothing usable remains.

  1. Read social rows from Repeater field.
  2. Normalize rows to name/url pairs.
  3. Filter rows where URL is empty.
  4. Render list only when filtered rows remain.
  5. Log hidden state for editorial follow-up.
wp-content/themes/clinic-pro/partials/social-links-guard.php
<?php

declare(strict_types=1);

add_action('acf/init', function (): void {
acf_add_local_field_group([
'key' => 'group_social_links_footer',
'title' => 'Footer Social Links',
'fields' => [
[
'key' => 'field_social_links',
'label' => 'Social Links',
'name' => 'social_links',
'type' => 'repeater',
'button_label' => 'Add Social Link',
'sub_fields' => [
[
'key' => 'field_social_platform',
'label' => 'Platform',
'name' => 'social_platform',
'type' => 'text',
],
[
'key' => 'field_social_url',
'label' => 'URL',
'name' => 'social_url',
'type' => 'url',
],
],
],
],
'location' => [[[
'param' => 'post_type',
'operator' => '==',
'value' => 'post',
]]],
]);
});

add_action('template_redirect', function (): void {
if (!is_single()) {
return;
}

$rows = get_field('social_links');
$rows = is_array($rows) ? $rows : [];

$validRows = array_values(array_filter($rows, static function (array $row): bool {
return !empty($row['social_url']);
}));

if (count($validRows) === 0) {
error_log('[social-guard] hidden_empty_cluster post=' . get_the_ID());
return;
}

echo '<ul class="social-links">';

foreach ($validRows as $row) {
$platform = (string) ($row['social_platform'] ?? 'Social');
$url = (string) $row['social_url'];

echo '<li><a href="' . esc_url($url) . '" rel="noopener noreferrer">' . esc_html($platform) . '</a></li>';
}

echo '</ul>';
});
terminal: command
wp post create --post_title="Fallback Social Drill" --post_status=publish --post_type=post
wp eval '$id=(int)get_page_by_title("Fallback Social Drill", OBJECT, "post")->ID; update_field("social_links", [["social_platform"=>"LinkedIn","social_url"=>"https://linkedin.com/company/clinic"],["social_platform"=>"Instagram","social_url"=>""],["social_platform"=>"YouTube","social_url"=>"https://youtube.com/@clinic"]], $id); $rows=get_field("social_links",$id); $valid=array_values(array_filter((array)$rows, fn($r)=>!empty($r["social_url"]))); echo count($valid) . PHP_EOL;'
terminal: output
Success: Created post 1361.
2
note

Filtering before rendering is cleaner than rendering and hiding empty rows with CSS.

Use Case 3 — Edge case: Flexible layout exists but inner required fields are missing

A page has a hero_section layout row, but migration left hero_title empty and CTA URL missing. Fragile templates emit broken markup. Robust templates validate inner fields and either fallback or skip safely.

❌ Fragile Pattern

wp-content/themes/clinic-pro/template-parts/flex-fallback-fragile.php
<?php

declare(strict_types=1);

add_action('template_redirect', function (): void {
if (!have_rows('landing_sections')) {
return;
}

while (have_rows('landing_sections')) {
the_row();

if (get_row_layout() === 'hero_section') {
echo '<h2>' . esc_html((string) get_sub_field('hero_title')) . '</h2>';
echo '<a href="' . esc_url((string) get_sub_field('hero_cta_url')) . '">'
. esc_html((string) get_sub_field('hero_cta_label')) . '</a>';
}
}
});

✅ Robust Pattern

wp-content/themes/clinic-pro/template-parts/flex-fallback-robust.php
<?php

declare(strict_types=1);

add_action('template_redirect', function (): void {
if (!is_page() || !have_rows('landing_sections')) {
return;
}

while (have_rows('landing_sections')) {
the_row();
$layout = (string) get_row_layout();

if ($layout !== 'hero_section') {
continue;
}

$title = (string) get_sub_field('hero_title');
$ctaLabel = (string) get_sub_field('hero_cta_label');
$ctaUrl = (string) get_sub_field('hero_cta_url');
$globalCtaLabel = (string) get_field('global_cta_label', 'option');
$globalCtaUrl = (string) get_field('global_cta_url', 'option');

if ($title === '') {
$title = 'Talk to our specialists today';
}

if ($ctaLabel === '' || $ctaUrl === '') {
$ctaLabel = $globalCtaLabel;
$ctaUrl = $globalCtaUrl;
}

echo '<section class="hero">';
echo '<h2>' . esc_html($title) . '</h2>';

if ($ctaLabel !== '' && $ctaUrl !== '') {
echo '<a class="hero-cta" href="' . esc_url($ctaUrl) . '">' . esc_html($ctaLabel) . '</a>';
}

echo '</section>';

error_log('[flex-fallback] page=' . get_the_ID() . ' title_fallback=' . (int) ($title === 'Talk to our specialists today'));
}
});
terminal: command
wp eval 'update_field("global_cta_label", "Book Consultation", "option"); update_field("global_cta_url", "/book", "option");'
wp eval '$id=1360; update_field("landing_sections", [["acf_fc_layout"=>"hero_section","hero_title"=>"","hero_cta_label"=>"","hero_cta_url"=>""]], $id); $rows=get_field("landing_sections",$id); echo $rows[0]["acf_fc_layout"] . "|" . ($rows[0]["hero_title"] === "" ? "empty-title" : "has-title") . PHP_EOL;'
wp eval '$rows=get_field("landing_sections",1360); $title=$rows[0]["hero_title"] ?? ""; $label=$rows[0]["hero_cta_label"] ?? ""; $url=$rows[0]["hero_cta_url"] ?? ""; if($title===""){$title="Talk to our specialists today";} if($label===""||$url===""){$label=get_field("global_cta_label","option"); $url=get_field("global_cta_url","option");} echo $title . "|" . $label . "|" . $url . PHP_EOL;'
terminal: output
hero_section|empty-title
Talk to our specialists today|Book Consultation|/book
warning

A layout row existing does not mean it is render-ready. Validate required inner fields before output.

Common Mistakes

MistakeRoot CauseWhat Breaks in ProductionCorrect Pattern
Rendering wrappers before value checksMarkup emitted without usable contentEmpty sections and layout gapsGuard entire block with effective-value checks
Hardcoding fallback text in multiple filesNo single fallback source of truthInconsistent copy and hard refactorsStore global defaults in option fields
Treating any row existence as complete dataMissing subfields ignoredBroken CTA links, blank headingsValidate subfield completeness per layout
No fallback precedence policyDevelopers improvise per templateConflicting behavior across pagesDefine page → option → hide precedence explicitly
Missing CLI checks for empty-state pathsQA covers only happy pathEdge cases leak to productionAdd wp eval fallback-path tests per component
Silent fallback usage with no loggingContent issues remain invisibleEditorial quality declines over timeLog source and fallback hits for monitoring
Deep Dive: Why Empty-State Bugs Survive Launch Checklists

Most manual QA uses fully populated demo content, so empty-state logic remains untested. Real production content includes drafts, migrated posts, and partial updates where required fields are temporarily missing. Bugs surface only on specific pages and appear intermittent. Without a formal fallback policy, different components fail in different ways. The fix is to test fallback paths explicitly with CLI and keep logs that reveal how often defaults are being used.

wp eval '$id=1360; $page=(string)get_field("page_contact_phone",$id); $global=(string)get_field("global_support_phone","option"); echo "source=" . ($page!==""?"page":($global!==""?"option":"none")) . PHP_EOL;'

Best Practices

  1. Define fallback precedence per component and document it near code.
  2. Hide optional blocks when no effective value exists; do not render empty wrappers.
  3. Use option-level defaults for truly global values (phone, default CTA, legal text).
  4. Validate inner fields for Flexible layouts before rendering layout markup.
  5. Log fallback source (page, option, none) for production observability.
  6. Add CLI checks for both populated and empty states to your release checklist.
  7. Keep fallback copy and links centrally managed to avoid drift.

Hands-On Practice

Exercise 1: Implement page → option phone fallback

Create wp-content/themes/clinic-pro/partials/contact-cta-fallback.php and run:

wp eval 'update_field("global_support_phone", "+65 6700 1234", "option");'
wp eval '$id=1360; update_field("page_contact_phone", "", $id); $page=(string)get_field("page_contact_phone",$id); $global=(string)get_field("global_support_phone","option"); echo ($page!==""?$page:$global) . PHP_EOL;'

After completing this exercise, output should be:

+65 6700 1234

Exercise 2: Verify page override wins over global fallback

Run:

wp eval '$id=1360; update_field("page_contact_phone", "+65 6999 0000", $id); $page=(string)get_field("page_contact_phone",$id); $global=(string)get_field("global_support_phone","option"); echo ($page!==""?$page:$global) . PHP_EOL;'

After completing this exercise, output should be:

+65 6999 0000

Run:

wp eval '$id=1361; $rows=[["social_platform"=>"LinkedIn","social_url"=>"https://linkedin.com/company/clinic"],["social_platform"=>"Instagram","social_url"=>""],["social_platform"=>"YouTube","social_url"=>"https://youtube.com/@clinic"]]; update_field("social_links",$rows,$id); $saved=get_field("social_links",$id); $valid=array_values(array_filter((array)$saved, fn($r)=>!empty($r["social_url"]))); echo count($valid) . PHP_EOL;'

After completing this exercise, output should be:

2

Exercise 4: Simulate empty Flexible hero internals and resolve fallback

Run:

wp eval 'update_field("global_cta_label", "Book Consultation", "option"); update_field("global_cta_url", "/book", "option");'
wp eval '$id=1360; update_field("landing_sections", [["acf_fc_layout"=>"hero_section","hero_title"=>"","hero_cta_label"=>"","hero_cta_url"=>""]], $id);'
wp eval '$rows=get_field("landing_sections",1360); $title=$rows[0]["hero_title"] ?? ""; $label=$rows[0]["hero_cta_label"] ?? ""; $url=$rows[0]["hero_cta_url"] ?? ""; if($title===""){$title="Talk to our specialists today";} if($label===""||$url===""){$label=get_field("global_cta_label","option"); $url=get_field("global_cta_url","option");} echo $title . "|" . $label . "|" . $url . PHP_EOL;'

After completing this exercise, output should be:

Talk to our specialists today|Book Consultation|/book

Exercise 5: Add fallback-source audit check

Run:

wp eval '$id=1360; $page=(string)get_field("page_contact_phone",$id); $global=(string)get_field("global_support_phone","option"); echo "source=" . ($page!==""?"page":($global!==""?"option":"none")) . PHP_EOL;'

After completing this exercise, expected output pattern:

source=page

or

source=option

CLI Reference

CommandPurposeReal Example Output
wp eval 'echo get_field("global_support_phone", "option") . PHP_EOL;'Read global fallback phone+65 6700 1234
wp eval '$id=1360; echo (string)get_field("page_contact_phone",$id) . PHP_EOL;'Read page-specific override+65 6999 0000
wp eval '$id=1360; $page=(string)get_field("page_contact_phone",$id); $global=(string)get_field("global_support_phone","option"); echo ($page!==""?$page:$global) . PHP_EOL;'Resolve effective fallback value+65 6999 0000
wp eval '$id=1361; $rows=get_field("social_links",$id); $valid=array_values(array_filter((array)$rows, fn($r)=>!empty($r["social_url"]))); echo count($valid) . PHP_EOL;'Count valid social links after filtering empties2
wp eval '$rows=get_field("landing_sections",1360); echo $rows[0]["acf_fc_layout"] . PHP_EOL;'Verify first Flexible layout slughero_section
wp eval '$rows=get_field("landing_sections",1360); print_r($rows[0]);'Inspect inner fields for fallback checksArray ( [hero_title] => ... )
wp post meta get 1360 page_contact_phoneInspect raw per-page value for precedence audits+65 6999 0000
`wp plugin list --status=activegrep advanced-custom-fields-pro`Confirm ACF Pro availability for options/flexible workflows

What's Next

tip

Revisit this lesson whenever your team reports "it works on one page but not another" for ACF-driven components; that symptom often indicates missing fallback or empty-state policy.