Relation Modal State Management#
The relation modal in Strapi's Content Manager lets editors open, edit, and create related documents inline — without leaving the parent edit view. It manages its own reducer-based state, syncs dirty state from the inner form, triggers parent document refetches on close, and connects newly created relations back to the parent form.
Key files:
RelationModal.tsx— all modal state, reducer, and form wiringDocumentActions.tsx—connectRelationToParent,UpdateAction,PublishActionuseDocument.ts— document fetching andgetInitialFormValues
Modal State: The Reducer#
The top-level RootRelationRenderer component owns all modal state via React.useReducer. The State shape tracks:
| Field | Purpose |
|---|---|
documentHistory | Stack of DocumentMeta objects representing the breadcrumb of opened relations |
isModalOpen | Whether the modal is currently shown |
hasUnsavedChanges | Lifted from the inner <Form> context |
confirmDialogIntent | null |
fieldToConnect | Path to the relation field on the parent that should receive the newly created document |
fieldToConnectUID | Component UID for the parent field (used when the parent component is also new) |
The reducer handles six Action types: GO_TO_RELATION, GO_BACK, GO_FULL_PAGE, GO_TO_CREATED_RELATION, CANCEL_CONFIRM_DIALOG, CLOSE_MODAL, and SET_HAS_UNSAVED_CHANGES.
The RelationModalProvider context exposes state and dispatch to all descendants. Nested relation modals (when a relation's relation is opened) reuse the same context via NestedRelationRenderer, which reads dispatch from the parent context rather than creating a new one.
Syncing hasUnsavedChanges from the Form#
The modal must be a parent of <Form> (to wrap it), but it also needs to know when the inner form is dirty. This creates an inversion problem. The solution is a one-way sync in RelationModalBody:
useForm('FormWatcher', state => state.modified)
→ hasUnsavedChanges = modified && !isSubmitting
→ useEffect → dispatch SET_HAS_UNSAVED_CHANGES
This lifts the form's modified flag up into the modal reducer . When hasUnsavedChanges is true, any navigation action (GO_BACK, CLOSE_MODAL, GO_FULL_PAGE, GO_TO_RELATION) sets confirmDialogIntent rather than executing immediately, triggering the discard-changes dialog .
Document Refetch on Modal Close#
When the modal closes without unsaved changes, handleCloseModal calls:
triggerRefetchDocument(rootDocumentMeta, /* preferCache= */ true)
This triggers useLazyGetDocumentQuery to re-fetch the root (parent) document from the API, refreshing any relation counts or field values that may have changed due to saves inside the modal. The preferCache: true argument means RTK Query serves a cached result if available, only hitting the network when the cache has been invalidated .
Note: The inline comment flags this as a workaround: ideally RTK Query's
invalidatesTagsmechanism would handle the refetch automatically, removing the need for the manual trigger .
A related concern exists in the list view: PR #24632 fixed the bulk-publish modal closing prematurely by changing the list view's loading gate from isFetching (any refetch) to isLoading (first fetch only). The pattern is analogous — modal visibility must not be tied to isFetching state, which fires on every background refetch.
Connecting a Created Relation Back to the Parent#
When a new document is created inside the relation modal and saved, it must be connected to the parent document's relation field. This happens in connectRelationToParent inside DocumentActions.tsx.
The function receives:
parentDataToUpdate— the parent's current form valuesfieldToConnect— the dot-notation path to the relation fielddata— the newly created document (documentId,locale)fieldToConnectUID— the component UID if the parent component is also unsaved
It uses lodash get / set / merge to build a { connect: [...] } patch and merges it into the parent data . It handles two cases:
- Field already present in parent data (e.g., existing component): directly sets
connect. - Field absent (e.g., a brand-new, unsaved component): computes the parent object path, adds
__componentUID, and includes an explicitdisconnect: [].
fieldToConnect and fieldToConnectUID are read from the relation modal state via useRelationModal() in both UpdateAction and PublishAction.
Known Issue: Parent Form Reset on Relation Modal Close (PR #26535)#
Bug: When creating a relation from inside a newly added dynamic-zone component, closing the relation modal after saving the relation discarded unsaved edits on the parent form.
Root cause: parentDataToUpdate was sourced from getInitialFormValues() — the document's server-fetched initial values — rather than the parent form's current in-memory values. Any fields filled in after the last save (e.g., a component title typed before opening the relation modal) were lost.
Fix (PR #26535, open as of this writing): Adds parentFormValues to the modal State, captured from the parent form at the moment the relation modal opens. prepareParentDataForRelationUpdate uses these captured values as the base (falling back to initial form values) before calling connectRelationToParent. This ensures unsaved edits to the parent are preserved when the relation is connected back.
The fix also updates connectRelationToParent to merge into existing connect entries for the target field (deduping by documentId + locale), preventing existing pending relations in the same field from being dropped.