Deny-by-default React UI
Learn React Authentication, Authorization, Identity & Permission/ACL - Part 050
Deny-by-default React UI for authorization-aware applications: unknown permission state, safe rendering, permission boundaries, skeletons, hidden vs disabled controls, field-level denial, route layout policy, SSR/hydration, testing, and design-system integration.
Part 050 — Deny-by-default React UI
Deny-by-default is usually described as a backend authorization principle.
It is also a frontend rendering principle.
In React, deny-by-default means:
If permission is unknown, do not expose the capability.
If permission is stale, do not expose risky capability.
If permission is denied, render a safe alternative.
If the user must recover, show a controlled recovery path.
This does not make frontend a security boundary.
It makes the frontend honest.
A deny-by-default UI avoids accidental disclosure, confusing affordances, stale privilege exposure, and dangerous optimistic action paths.
1. The core mistake
Many React apps implement permission like this:
{user.role === 'admin' && <DeleteButton />}
Or:
{permissions.includes('case.approve') && <ApproveButton />}
These snippets have several hidden assumptions:
user exists
role is fresh
permission list is loaded
permission list is tenant-scoped
permission applies to this resource
permission applies to this resource state
permission does not require step-up auth
permission has not been revoked
button visibility is enough
Most of those assumptions are false in production.
A better model starts with three states:
allowed
denied
unknown
Then adds freshness:
fresh
stale
And sometimes recovery:
login required
step-up required
request access possible
tenant switch required
2. Permission decision model
Use an explicit decision type.
export type PermissionDecision =
| {
kind: 'allowed';
action: string;
resource?: ResourceRef;
freshness: 'fresh' | 'stale';
evaluatedAt: string;
permissionEpoch: number;
}
| {
kind: 'denied';
action: string;
resource?: ResourceRef;
reason:
| 'not_authenticated'
| 'not_member'
| 'missing_permission'
| 'resource_locked'
| 'state_not_allowed'
| 'step_up_required'
| 'tenant_mismatch'
| 'permission_revoked';
freshness: 'fresh' | 'stale';
recoverable: boolean;
evaluatedAt: string;
permissionEpoch: number;
}
| {
kind: 'unknown';
action: string;
resource?: ResourceRef;
reason:
| 'not_loaded'
| 'loading'
| 'network_error'
| 'stale_session'
| 'permission_contract_error';
freshness: 'stale';
};
export type ResourceRef = {
type: string;
id?: string;
version?: string;
};
The important design choice:
unknown is not allowed
stale allowed is not always renderable
A boolean cannot represent that.
3. Rendering policy
Define rendering policy centrally.
export type RenderRisk = 'low' | 'medium' | 'high';
export type PermissionRenderDecision =
| { render: 'show' }
| { render: 'hide' }
| { render: 'disable'; reason: string }
| { render: 'skeleton' }
| { render: 'forbidden'; reason: string; recoverable: boolean };
export function decidePermissionRender(input: {
decision: PermissionDecision;
risk: RenderRisk;
mode: 'affordance' | 'content' | 'route' | 'field' | 'mutation';
}): PermissionRenderDecision {
const { decision, risk, mode } = input;
if (decision.kind === 'unknown') {
if (mode === 'route' || mode === 'content') {
return { render: 'skeleton' };
}
return { render: 'hide' };
}
if (decision.freshness === 'stale') {
if (risk === 'high' || mode === 'mutation') {
return { render: 'skeleton' };
}
}
if (decision.kind === 'allowed') {
return { render: 'show' };
}
if (mode === 'route' || mode === 'content') {
return {
render: 'forbidden',
reason: decision.reason,
recoverable: decision.recoverable,
};
}
if (decision.reason === 'step_up_required') {
return { render: 'disable', reason: decision.reason };
}
return { render: 'hide' };
}
This gives the team a consistent answer to:
Should this button be hidden?
Should this route show forbidden?
Should this field be read-only?
Should this content skeleton while loading?
Should stale allowed permission be trusted?
Without central policy, every component invents a different security posture.
4. Hide vs disable vs forbidden
Deny-by-default does not always mean hiding everything.
Different UI surfaces require different treatment.
| Surface | Unknown permission | Denied permission | Notes |
|---|---|---|---|
| Destructive button | hide/skeleton | hide or disabled with reason | Avoid tempting unavailable action. |
| Primary workflow action | skeleton | disabled with explanation | User may need to understand why blocked. |
| Route content | skeleton | forbidden page | Direct URL access needs clear response. |
| Sidebar item | hide/skeleton | hide | Navigation should avoid clutter. |
| Form field | skeleton/read-only | hidden or read-only | Depends on sensitivity. |
| Table row action | hide/skeleton | hide/disabled | Row-level decisions differ. |
| Admin tab | skeleton | hide or forbidden | Deep link may require forbidden. |
| Sensitive data field | skeleton | hide/redact | Do not disclose existence/details unnecessarily. |
The rule is not "always hide".
The rule is:
Do not expose capabilities as usable unless allowed and fresh enough.
5. A Can component
A good Can component should not accept raw role names.
Bad:
<Can role="admin">
<DeleteButton />
</Can>
Better:
<Can
action="case.approve"
resource={{ type: 'case', id: case.id, version: case.version }}
risk="high"
fallback="hide"
>
<ApproveCaseButton caseId={case.id} />
</Can>
Implementation skeleton:
type CanProps = {
action: string;
resource?: ResourceRef;
risk?: RenderRisk;
mode?: 'affordance' | 'content' | 'route' | 'field' | 'mutation';
fallback?: 'hide' | 'skeleton' | 'forbidden' | 'disable';
children: React.ReactNode;
};
export function Can({
action,
resource,
risk = 'medium',
mode = 'affordance',
fallback = 'hide',
children,
}: CanProps) {
const decision = usePermissionDecision(action, resource);
const renderDecision = decidePermissionRender({ decision, risk, mode });
switch (renderDecision.render) {
case 'show':
return <>{children}</>;
case 'skeleton':
return fallback === 'skeleton' ? <PermissionSkeleton /> : null;
case 'forbidden':
return fallback === 'forbidden' ? (
<ForbiddenPanel reason={renderDecision.reason} />
) : null;
case 'disable':
return fallback === 'disable' ? (
<DisabledPermissionWrapper reason={renderDecision.reason}>
{children}
</DisabledPermissionWrapper>
) : null;
case 'hide':
default:
return null;
}
}
The component is a projection layer.
The mutation still needs server authorization.
6. useCan() hook
Hooks are better when the component needs custom rendering.
type UseCanResult = {
allowed: boolean;
denied: boolean;
unknown: boolean;
stale: boolean;
reason?: string;
recoverable?: boolean;
decision: PermissionDecision;
};
export function useCan(action: string, resource?: ResourceRef): UseCanResult {
const decision = usePermissionDecision(action, resource);
return {
allowed: decision.kind === 'allowed' && decision.freshness === 'fresh',
denied: decision.kind === 'denied',
unknown: decision.kind === 'unknown',
stale: decision.freshness === 'stale',
reason: decision.kind === 'denied' ? decision.reason : undefined,
recoverable: decision.kind === 'denied' ? decision.recoverable : undefined,
decision,
};
}
Use it carefully:
function ApproveCaseFooter({ caseData }: { caseData: CaseDto }) {
const approve = useCan('case.approve', {
type: 'case',
id: caseData.id,
version: caseData.version,
});
if (approve.unknown || approve.stale) {
return <FooterSkeleton />;
}
if (approve.denied) {
return <PermissionHint reason={approve.reason} />;
}
return <ApproveCaseButton caseId={caseData.id} />;
}
Avoid this:
if (!approve.allowed) return null;
That collapses denied, unknown, stale, and error into the same behavior.
Sometimes that is fine for a small menu item. It is not fine for workflow surfaces.
7. Route-level deny-by-default
Routes should not render sensitive content until the session and route permission are known.
export async function loader({ request, params }: LoaderArgs) {
const session = await requireSession(request);
const caseData = await getCase(params.caseId);
const decision = await checkPermission({
subject: session.subject,
tenant: session.activeTenant,
action: 'case.view',
resource: {
type: 'case',
id: caseData.id,
version: caseData.version,
},
});
if (!decision.allowed) {
throw forbidden(decision);
}
return {
caseData,
permissionProjection: await getPermissionProjection({
subject: session.subject,
tenant: session.activeTenant,
resource: {
type: 'case',
id: caseData.id,
version: caseData.version,
},
}),
};
}
This does two things:
The route does not render case content if case.view is denied.
The page receives a permission projection for secondary actions.
Do not render route content first and then hide it in useEffect.
That creates data flash.
8. Layout-level deny-by-default
Authenticated layouts often leak affordances.
Example:
Admin sidebar visible while permission still loading.
Tenant switcher shows organizations from previous session.
Command palette includes actions user cannot perform.
Breadcrumb reveals resource title before view permission is known.
A layout should distinguish shell readiness from permission readiness.
function AppLayout() {
const session = useSession();
const navigation = useNavigationProjection();
if (session.status === 'unknown' || session.status === 'loading') {
return <AppShellSkeleton />;
}
if (session.status === 'anonymous') {
return <Navigate to="/login" replace />;
}
return (
<AppShell
tenant={session.activeTenant}
navigation={navigation.status === 'success' ? navigation.data : undefined}
navigationFallback={<NavigationSkeleton />}
/>
);
}
Do not render a default admin navigation while waiting.
Skeleton is safer than optimistic exposure.
9. Field-level deny-by-default
Field-level authorization is subtle.
There are several states:
field can be viewed and edited
field can be viewed but not edited
field can be viewed only when masked
field cannot be viewed
field is unknown because permission is loading
Model it explicitly.
type FieldPermission =
| { visibility: 'visible'; editability: 'editable' }
| { visibility: 'visible'; editability: 'readonly'; reason?: string }
| { visibility: 'masked'; reason?: string }
| { visibility: 'hidden'; reason?: string }
| { visibility: 'unknown' };
Rendering:
function AuthorizedField({
label,
value,
permission,
}: {
label: string;
value: React.ReactNode;
permission: FieldPermission;
}) {
switch (permission.visibility) {
case 'unknown':
return <FieldSkeleton label={label} />;
case 'hidden':
return null;
case 'masked':
return <Field label={label} value="••••••" />;
case 'visible':
return <Field label={label} value={value} />;
}
}
Do not send sensitive field values to the browser and merely hide them with CSS.
If the user cannot view a field, the API should omit/redact it.
Frontend field-level deny-by-default prevents UI mistakes. Backend field-level projection prevents data exposure.
10. Form submit deny-by-default
A form submit is a mutation.
Mutations require stricter behavior than passive UI.
Rules:
Do not enable submit if mutation permission is unknown.
Do not enable submit if permission is stale for high-risk action.
Recheck permission immediately before submit when action is sensitive.
Handle 403 as expected outcome.
Never rely on disabled fields for backend enforcement.
Pattern:
function CaseEditForm({ caseData }: { caseData: CaseDto }) {
const update = useCan('case.update', {
type: 'case',
id: caseData.id,
version: caseData.version,
});
const mutation = useMutation({
mutationFn: updateCase,
onError(error) {
if (isAuthorizationProblem(error)) {
permissionCache.invalidateForProblem(error);
}
},
});
const submitDisabled =
update.unknown || update.stale || update.denied || mutation.isPending;
return (
<form onSubmit={handleSubmit((values) => mutation.mutate(values))}>
<CaseFields mode={update.allowed ? 'edit' : 'readonly'} />
<button type="submit" disabled={submitDisabled}>
Save changes
</button>
{update.denied ? <PermissionHint reason={update.reason} /> : null}
</form>
);
}
This is deny-by-default at the mutation affordance layer.
The API still validates case.update.
11. Table and bulk action deny-by-default
Bulk actions are dangerous because eligibility differs per row.
Example:
User selects 20 cases.
They can approve 13.
They can view 20.
They can comment on 18.
They can close 0.
The UI should not expose a bulk action as globally allowed unless every selected row is eligible or the product explicitly supports partial execution.
Model:
type BulkPermissionSummary = {
action: string;
totalSelected: number;
allowedCount: number;
deniedCount: number;
unknownCount: number;
mode: 'all-or-nothing' | 'partial-allowed';
};
Rendering:
function BulkApproveButton({ summary }: { summary: BulkPermissionSummary }) {
if (summary.unknownCount > 0) {
return <ButtonSkeleton width="bulk-approve" />;
}
if (summary.mode === 'all-or-nothing' && summary.deniedCount > 0) {
return (
<button disabled title="Some selected cases cannot be approved">
Approve selected
</button>
);
}
if (summary.allowedCount === 0) {
return null;
}
return <button>Approve {summary.allowedCount} selected</button>;
}
Do not infer bulk permission from the first selected row.
12. Sensitive content deny-by-default
Capabilities are not the only thing that need permission.
Content may also need permission.
Examples:
PII fields
case notes
internal audit comments
enforcement evidence
financial values
security settings
admin-only metadata
Deny-by-default for content means:
Do not fetch sensitive content unless view permission is allowed.
Do not render sensitive placeholders that reveal too much.
Do not include sensitive data in props for hidden components.
Do not put sensitive data in data attributes or logs.
Do not expose sensitive data through error messages.
Bad:
<SensitiveField value={user.ssn} hidden={!canViewSsn} />
Better:
{profile.ssn.kind === 'redacted' ? (
<RedactedField label="SSN" />
) : (
<Field label="SSN" value={profile.ssn.value} />
)}
The API response should already be redacted.
{
"name": "Ari",
"ssn": {
"kind": "redacted",
"reason": "missing_permission"
}
}
Frontend should not receive what it cannot show.
13. Unknown state is a first-class state
React apps often treat loading as a spinner problem.
For auth, loading is a security state.
session unknown
permission unknown
resource unknown
tenant unknown
assurance unknown
policy version unknown
Each unknown state has a safe rendering default.
| Unknown state | Safe default |
|---|---|
| Session unknown | show shell/splash, do not render app data |
| Tenant unknown | block tenant-scoped UI |
| Permission unknown | hide/skeleton action affordances |
| Resource unknown | do not render resource-dependent permission |
| Field permission unknown | skeleton or redacted placeholder |
| Assurance unknown | do not expose high-risk action |
Avoid:
const canApprove = permissions?.includes('case.approve') ?? true;
This is allow-by-default.
Prefer:
const canApprove = permissions?.includes('case.approve') ?? false;
But even better, do not collapse unknown into boolean.
14. SSR and hydration
SSR introduces a special risk:
Server renders with one auth state.
Client hydrates with another auth state.
Examples:
Session expired after SSR response was generated.
User logged out in another tab before hydration.
Permission epoch changed while page was loading.
Tenant switched in another tab.
Deny-by-default SSR strategy:
Server performs route-level authorization before rendering sensitive content.
Server includes permission/session version in the HTML payload.
Client reconciles versions during hydration.
Client removes sensitive UI if session projection is stale.
High-risk actions remain disabled until client confirms fresh permission.
Hydration guard sketch:
function useHydrationAuthReconciliation(serverAuthVersion: AuthVersion) {
const session = useSessionProjection();
useEffect(() => {
if (!session) return;
if (session.permissionEpoch < serverAuthVersion.permissionEpoch) {
queryClient.removeQueries({ queryKey: ['permissions'] });
queryClient.invalidateQueries({ queryKey: ['session'] });
}
}, [session, serverAuthVersion.permissionEpoch]);
}
SSR should reduce flashes, not create new ones.
15. Suspense and deny-by-default
Suspense can help express unknown state, but it should not hide policy decisions.
Good:
<Suspense fallback={<PermissionSkeleton />}>
<PermissionedCaseActions caseId={caseId} />
</Suspense>
Risky:
<Suspense fallback={<AdminActions />}>...</Suspense>
The fallback is rendered while permission is unknown.
So the fallback must also be safe.
Rule:
Suspense fallback for protected UI must be less privileged than resolved UI.
For high-risk actions, fallback should usually be skeleton or nothing.
16. Error states
Network error is not authorization.
But it still should not become allow.
type PermissionLoadState =
| { status: 'loading' }
| { status: 'success'; projection: PermissionProjection }
| { status: 'error'; error: unknown }
| { status: 'stale'; projection?: PermissionProjection };
Rendering policy:
loading -> skeleton/hide
error -> safe unavailable state + retry
stale -> hide high-risk actions, maybe show low-risk read-only UI
success denied -> forbidden/reason/hide depending surface
success allowed -> render
Do not do this:
if (permissionQuery.isError) return <AdminPanel />;
Fail closed.
But fail closed with recoverability:
if (permissionQuery.isError) {
return (
<PermissionUnavailable
message="We could not verify your access right now."
onRetry={() => permissionQuery.refetch()}
/>
);
}
17. Access request UX
Deny-by-default should not produce dead ends when access can be requested.
Denied decision can include recovery metadata.
{
"allowed": false,
"reason": "missing_permission",
"recoverable": true,
"recovery": {
"type": "request_access",
"target": {
"permission": "case.approve",
"resourceType": "case",
"resourceId": "case_123"
}
}
}
UI:
function PermissionHint({ decision }: { decision: DeniedDecision }) {
if (decision.recovery?.type === 'request_access') {
return (
<Alert>
You do not have permission to approve this case.
<RequestAccessButton target={decision.recovery.target} />
</Alert>
);
}
if (decision.reason === 'step_up_required') {
return <StepUpPrompt action={decision.action} />;
}
return <Alert>You do not have access to this action.</Alert>;
}
The message should help the user without revealing sensitive policy internals.
18. Design system integration
If each product screen manually implements permission rendering, the system will drift.
Permission should be built into the design system.
Examples:
AuthorizedButton
AuthorizedMenuItem
AuthorizedTab
AuthorizedRouteSection
AuthorizedField
AuthorizedBulkAction
PermissionHint
ForbiddenPanel
StepUpButton
RequestAccessButton
Button API:
type AuthorizedButtonProps = ButtonProps & {
action: string;
resource?: ResourceRef;
risk?: RenderRisk;
deniedBehavior?: 'hide' | 'disable' | 'explain';
};
function AuthorizedButton({
action,
resource,
risk = 'medium',
deniedBehavior = 'hide',
children,
...buttonProps
}: AuthorizedButtonProps) {
const permission = useCan(action, resource);
if (permission.unknown || permission.stale) {
return <ButtonSkeleton />;
}
if (permission.denied) {
if (deniedBehavior === 'disable') {
return (
<button {...buttonProps} disabled title={permission.reason}>
{children}
</button>
);
}
if (deniedBehavior === 'explain') {
return <PermissionHint reason={permission.reason} />;
}
return null;
}
return <button {...buttonProps}>{children}</button>;
}
This moves policy rendering consistency out of individual feature teams.
19. Deny-by-default and analytics
Analytics can accidentally defeat deny-by-default.
Examples:
Logging hidden menu labels for denied users.
Sending resource titles in analytics before view permission is confirmed.
Sending form field values even when field should be redacted.
Recording session replay for sensitive screens.
Rules:
Analytics events should use stable action/resource type names, not sensitive values.
Do not log denied sensitive content.
Do not include raw permission projection if it contains policy-sensitive metadata.
Respect tenant and privacy boundaries.
Disable or mask session replay on sensitive screens.
Example:
track('permission_denied_ui', {
action: 'case.approve',
resourceType: 'case',
reason: decision.reason,
routeId: 'case.detail',
});
Avoid:
track('permission_denied_ui', {
caseTitle: caseData.title,
investigatorName: caseData.assignee.name,
policyExpression: decision.debugPolicy,
});
20. Deny-by-default and optimistic UI
Optimistic UI is dangerous for authorization.
Acceptable optimistic UI:
optimistically adding a comment after user already has fresh comment permission
optimistically toggling a low-risk preference with server rollback
Dangerous optimistic UI:
optimistically approving a case
optimistically deleting an account
optimistically changing roles
optimistically exposing sensitive data while permission loads
Pattern:
function canUseOptimisticUi(input: {
actionRisk: RenderRisk;
permission: UseCanResult;
}) {
return (
input.actionRisk === 'low' &&
input.permission.allowed &&
!input.permission.stale &&
!input.permission.unknown
);
}
High-risk workflows should wait for server confirmation.
21. Deny-by-default in regulated workflows
In regulated case-management systems, authorization often depends on workflow state.
Example:
Case draft can be edited by assigned officer.
Submitted case can be reviewed by supervisor.
Escalated case can be handled only by escalation team.
Closed case can be reopened only with special permission and justification.
UI should derive actions from server-provided allowed transitions.
{
"case": {
"id": "case_123",
"state": "submitted",
"version": "18"
},
"allowedTransitions": [
{
"action": "case.request_changes",
"label": "Request changes",
"requiresJustification": true
},
{
"action": "case.escalate",
"label": "Escalate",
"requiresJustification": true,
"requiresStepUp": true
}
]
}
React renders the transitions it is given.
It does not reconstruct the workflow authorization model from role names.
22. UX copy for denied states
Bad denied copy:
You are not allowed.
Better:
You do not have access to approve this case.
Better with recovery:
You need supervisor approval permission to approve this case. You can request access.
But be careful not to reveal sensitive internal policy.
Avoid:
You are denied because policy rule enforcement.case.approve.supervisor.v7 failed at line 38.
Use stable reason codes internally, human copy externally.
const reasonMessage: Record<string, string> = {
not_authenticated: 'Please sign in to continue.',
not_member: 'You are not a member of this organization.',
missing_permission: 'You do not have access to this action.',
resource_locked: 'This item is locked.',
state_not_allowed: 'This action is not available in the current state.',
step_up_required: 'Please verify your identity again to continue.',
tenant_mismatch: 'Switch to the correct organization to continue.',
permission_revoked: 'Your access changed. Refresh and try again.',
};
23. Test cases
Deny-by-default should be tested as an invariant.
23.1 Unknown permission
Given permission query is loading
When case detail page renders
Then approve/delete buttons are not usable
And sensitive fields are not displayed
23.2 Denied permission
Given user lacks case.approve
When case detail page renders
Then approve button is hidden or disabled according to design policy
And direct POST /approve is still denied by server
23.3 Stale permission
Given user had case.update
And permission epoch advanced
When form is still open
Then submit is disabled until permission revalidates
23.4 Tenant switch
Given user is admin in Org A and viewer in Org B
When user switches to Org B
Then Org A admin controls disappear before Org B content renders
23.5 SSR hydration
Given server rendered page with permission epoch 10
And client session observes epoch 11
When hydration completes
Then high-risk actions are hidden until refetched
23.6 Error state
Given permission API fails
When page renders
Then UI does not default to allowed
And user sees retry/unavailable state
24. Static review checklist
When reviewing a React PR, look for these smells:
user.role === "admin" inside feature component
permissions.includes(...) without resource context
permission loading defaults to allowed
permission error defaults to allowed
sensitive content fetched before view permission
button hidden but API mutation not protected
route content rendered before auth loader completes
tenant id missing from permission query key
resource version missing from permission query key
field hidden only with CSS
stale permission allowed for destructive action
optimistic UI for high-risk authorization workflow
A reviewer should ask:
What is the authoritative backend check?
What happens while permission is unknown?
What happens if permission changes after render?
What happens on direct URL access?
What happens on direct API access?
What cache key scopes this decision?
What test proves deny-by-default?
25. The invariant
Deny-by-default React UI can be summarized as one invariant:
No protected capability or sensitive data is exposed by the UI unless the app has a fresh enough positive authorization projection for the current subject, tenant, resource, action, and context.
Break it down:
protected capability -> button, menu, route, mutation, workflow transition
sensitive data -> field, document, evidence, profile detail, admin metadata
fresh enough -> not stale for this risk level
positive -> allowed, not unknown
current subject -> not previous login/session
current tenant -> not previous org/workspace
current resource -> not different object or version
current action -> not generic role inference
current context -> workflow state, assurance, time, environment
This is the UI side of least privilege.
It does not replace backend enforcement.
It makes the product behave like the backend policy actually matters.
26. Summary
Deny-by-default in React is not a slogan.
It is a rendering architecture.
It requires:
explicit permission decision types
unknown and stale as first-class states
central render policy
permission-aware design system components
route/data authorization before render
field-level data projection
safe fallback UI
server enforcement on every request
tests that prove the UI does not default to allowed
The payoff is large:
fewer stale privilege bugs
fewer confusing unavailable actions
less sensitive data flash
better direct URL behavior
cleaner permission reviews
stronger alignment between UI and server authorization
A top-tier React auth system is not the one with the most clever guard component.
It is the one where the default rendering posture is safe, consistent, and boring.
References
- OWASP Cheat Sheet Series — Authorization Cheat Sheet.
- OWASP Top 10 — Broken Access Control.
- OWASP Cheat Sheet Series — Session Management Cheat Sheet.
- React documentation — Conditional Rendering.
- React Router documentation — Data Loading, Actions, Error Boundaries, Pending UI.
- MDN Web Docs — Cache-Control header and HTTP caching.
You just completed lesson 50 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.