Skip to content

Replace hand-picked redux concepts with react hooks...? #26849

@adamziel

Description

@adamziel

Could we do away with most redux concepts that we use today, and instead rely on React hooks?

This is an audacious proposal directly building up on findings from 26692. Not much thought given to cost/benefit analysis yet but the amount of work involved is potentially huge. Even if this ends up not being pursued, having a discussion would be beneficial anyway. Let's talk!

Our redux actions and selectors have complex needs such as invoking side-effects or actions and selectors from other stores. To enable these interactions, we have concepts like:

  • Registry selectors
  • yield anotherAction()
  • Async actions, controls, thunk middleware

At the same time, we do all this things in React components using:

  • useSelect
  • useDispatch
  • useEffect and promises

I wonder - what are the advantages of using the former over the latter? These ideas seem to be interchangeable, only using both means more code paths to maintain plus twice the learning and a higher barrier of entry. Let’s see how React hooks could replace the existing concepts side by side:

Actions with yield

These two seem to be equivalent and interchangeable:

export function* undo() {
	const undoEdit = yield controls.select( 'core', 'getUndoEdit' );
	if ( ! undoEdit ) {
		return;
	}
	yield {
		type: 'EDIT_ENTITY_RECORD',
		...undoEdit,
		meta: {
			isUndo: true,
		},
	};
}
export function useUndo() {
    const undoEdit = useSelect( select => select('core').getUndoEdit() );
    const { editEntityRecord } = useDispatch( 'core' );
    const undo = useCallback(() => {
        if( ! undoEdit ) {
            return;
        }
        editEntityRecord( {
            ...undoEdit,
            meta: {
                isUndo: true
            }
        } );
    }, [ undoEdit ]);
    return undo;
}

Only with the latter one doesn’t need to know about generators, controls, or dispatching actions inline.

Controls

Similarly, controls seems to exist just to handle the async behavior and grant access to different stores. Consider the yield controls.select( 'core', 'getUndoEdit' ); part of the undo action above. This is the underlying select control:

// controls.js:
export const builtinControls = {
	[ SELECT ]: createRegistryControl(
		( registry ) => ( { storeKey, selectorName, args } ) =>
			registry.select( storeKey )[ selectorName ]( ...args )
	),
  // ...
}

With hooks, this control wouldn’t be required at all - its role would be fulfilled by useSelect(). The same goes for controls.dispatch.

Async controls

How about async controls, for example apiFetch? I find this:

export function* deleteEntityRecord( kind, name, recordId, query ) {
	/* ... */
	deletedRecord = yield apiFetch( {
		path,
		method: ‚DELETE’,
	} );
	/* ... */

To be the same as this:

export function useDeleteEntityRecord() {
	/* ... */
	return useCallback(async ( kind, name, recordId, query ) => {
		/* ... */
		await apiFetch( {
			path,
			method: ‚DELETE’,
		} );
		/* ... */
	});
}

In this case yield turned out to really be just a reimplementation of await - we „hack” generators to simulate async behavior. Actions invoke other actions that are really controls, a middleware (?) calls the control handler, and then the handler waits for the actual promise. Why not remove the indirection and await for the promise directly?

Registry selectors

This registry control:

// component.js:
const postContent = useSelect(
	( select ) => select( 'core/editor' ).getEditedPostContent(),
	[]
);

// selectors.js:
export const getEditedPostContent = createRegistrySelector(
	( select ) => ( state ) => {
		const postId = getCurrentPostId( state );
		const postType = getCurrentPostType( state );
		const record = select( 'core' ).getEditedEntityRecord(
			'postType',
			postType,
			postId
		);
		// ...
	}
);

Seems to be just this hook in disguise:

// component.js:
const postContent = useEditedPostContent();

// hooks.js
export const useEditedPostContent = () => {
	const { postId, postType, record } = useSelect( select => {
		const postId = select('core/editor').getCurrentPostId();
		const postType = select('core/editor').getCurrentPostType();
		const record = select( 'core' ).getEditedEntityRecord(
			'postType',
			postType,
			postId
		);
		return { postId, postType, record };
	});
	// ...
} );

Resolvers

Resolvers are particularly interesting. I didn’t quite figure out all the code paths that are running in order to make them work just yet, but it seems like this controls plays an important role:

[ RESOLVE_SELECT ]: createRegistryControl(
	( registry ) => ( { storeKey, selectorName, args } ) => {
		const method = registry.select( storeKey )[ selectorName ]
			.hasResolver
			? '__experimentalResolveSelect'
			: 'select';
		return registry[ method ]( storeKey )[ selectorName ]( ...args );
	}
)

I imagine that without controls, a canonical useSelect hook could be used to explicitly trigger the resolution when needed.

Public API

One downside of react hooks is that one could no longer call wp.data.select( 'x' ).myFunc() as easily. Or is it true? This isn't fully fleshed out yet, but I imagine exploring "bridge layer" could yield a solution:

const PublicApiCompat = memo(() => {
    useSelect(select => window.wp.data.select = select);
})

Other considerations

  • @wordpress/redux-routine would go away entirely.
  • At least some redux middlewares would go away.
  • Concepts of async actions, controls, registry selectors would all go away.
  • All that’s left would be pure reducers, pure selectors, and pure action creators. Other than that, the logic would live in hooks.
  • The code would be more approachable by new contributors.
  • Using hooks would improve debugging. Currently with async generators stack traces aren’t always informative plus stepping through the code is challenging.
  • A nice side-effect of this would be that it would remove the concept of registry selectors and thus make store-specific selectors possible.
  • It would break BC big time, unless of course we can build a „compatibility layer” to keep the existing API in place but really call the new one under the hood.

Anything else?

Am I missing anything? Is there anything that makes this a blunder or a technical impossibility? Really curious to learn what does everyone think. cc @noisysocks @draganescu @youknowriad @mtias @talldan @tellthemachines @gziolo @nerrad @jorgefilipecosta @ellatrix @kevin940726 @azaozz @aduth @nosolosw

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions