2

I am creating a validator for a field and triggering validation on update. I only care about the validation result of the current state. So if another update is triggered before the validation completes, I don't want the validation success to occur for the old piece of data.

I have read and believe that accessing the store in an action creator is somewhat of an anti-pattern in https://stackoverflow.com/a/35674575/816584.

I currently have the following:

export const USERNAME_VALIDATION_PENDING = 'USERNAME_VALIDATION_PENDING';
export const USERNAME_VALIDATION_SUCCESS = 'USERNAME_VALIDATION_SUCCESS';
export const USERNAME_VALIDATION_FAILURE = 'USERNAME_VALIDATION_FAILURE';
export const USERNAME_VALIDATION_ABORTED = 'USERNAME_VALIDATION_PENDING';

export interface Validator {
    validate(subject: string): Promise<void, Error>;
    cancel();
}

const cache = new WeakMap();

function memoize(obj: {}, subject: string, result: Error | true) {
    const map: {[subject: string]: Error | true} = cache.has(obj) ?
        cache.get(obj) : {};

    // TODO: Do consider caching just the message so the stack can get GC
    map[subject] = result;
    cache.set(obj, map);
}

function resultCacheP(obj: {}, subject: string) {
    const map: {[subject: string]: Error | true} = cache.has(obj) ?
        cache.get(obj) : {};
    return Object.keys(map).indexOf(subject) !== -1;
}

function resultCache(obj: {}, subject: string) {
    const map: {[subject: string]: Error | true} = cache.has(obj) ?
        cache.get(obj) : {};

    return map[subject];
}

export function requestUsernameValidation(username: string, validator: Validator) {
    return {
        type: USERNAME_VALIDATION_PENDING,
        isValidating: true,
        isValid: false,
        username,
        validator,
    }
}

export function receiveUsernameValidationSuccess() {
    return {
        type: USERNAME_VALIDATION_SUCCESS,
        isValidating: false,
        isValid: true,
    }
}

export function receiveUsernameValidationFailure(error: string) {
    return {
        type: USERNAME_VALIDATION_FAILURE,
        isValidating: false,
        isValid: false,
        error,
    }
}

export function validateUsername(username: string, validator: Validator) {
    return (dispatch) => {
        dispatch(requestUsernameValidation(username, validator));

        if (resultCacheP(validator, username)) {
            const result = resultCache(validator, username);
            if (result instanceof Error) {
                dispatch(receiveUsernameValidationSuccess());
            } else {
                dispatch(receiveUsernameValidationFailure(result.message));
            }
        } else {
            validator.validate(username)
                .then(() => {
                    memoize(validator, username, true);
                    dispatch(receiveUsernameValidationSuccess());
                })
                .catch((err: Error) => {
                    memoize(validator, username, err);
                    dispatch(receiveUsernameValidationFailure(err.message));
                });
        }
    };
}

I would like for the requestUsernameValidation to call cancel on the validator from the previous action if my state is currently validating to avoid a race condition where the previous validator finishes later and incorrectly updates the state.

I think I need to change requestUsernameValidation to a thunk to get the current state.

This is my first attempt at using Redux. Most tutorials I've read have really lean action creators, but a lot of real world projects I've seen have a lot of complex logic in them. Is this approach the correct way to solve this?

Steve Buzonas
  • 5,300
  • 1
  • 33
  • 55
  • 1
    Dan's opinion is that accessing state in action creators is usually bad, but I disagree. See my blog post [Idiomatic Redux: Thoughts on Thunks, Sagas, Abstraction, and Reusability](http://blog.isquaredsoftware.com/2017/01/idiomatic-redux-thoughts-on-thunks-sagas-abstraction-and-reusability/) for discussion of the pros and cons of doing so. – markerikson Jun 13 '17 at 17:05
  • @markerikson moving state transformations into what feels like a glorified global terrifies me. I've taken the approach of using action creators to log changes, and using thunks when a guided sequence of actions is necessary (usually due to branching). Then with reducers I can take the sequence of state changes to reproduce state at any point in time. Is that an appropriate use of Redux? – Steve Buzonas Jun 13 '17 at 18:53
  • Not quite sure I follow your comment. Yes, in one sense, Redux _is_ a "glorified global", but it also provides a pattern for separating "write logic" from the rest of the app. Can you maybe give a specific example of what concerns you have, and what you're doing now? Also, per your original question, I'm not sure what you mean by "cancel a previous action". Out of the box, calling `dispatch(someActionObject)` is synchronous - by the time it returns, the root reducer has run, and the state has been updated, so there's nothing to cancel. – markerikson Jun 13 '17 at 19:26

1 Answers1

1

Usually with input fields i would go with using a debounce method:

Quoting https://github.com/component/debounce

Creates and returns a new debounced version of the passed function that will postpone its execution until after wait milliseconds have elapsed since the last time it was invoked.

for example if you do

let waitTime = 300;
const debouncedValidateUsername = debounce((username, validator) => dispatch => { ... })

The validation will happen only 300 milliseconds after the last change and you will have the latest user input

UPDATE

Inspired by this great answer you can do the following

let cancellationAmbassador = {
  cancel() {}
};

function validate(validator, username, cancellationAmbassador) {
  return new Promise((resolve, reject) => {
    validator
      .validate(username)
      .then(() => {
        resolve("valid");
      })
      .catch((err: Error) => {
        reject("invalid");
      });
    cancellationAmbassador.cancel = function() {
      reject("cancellation");
    };
  });
}

export function validateUsername(username: string, validator: Validator) {
  return dispatch => {
    cancellationAmbassador.cancel();
    dispatch(requestUsernameValidation(username, validator));
    if (resultCacheP(validator, username)) {
      const result = resultCache(validator, username);
      if (result instanceof Error) {
        dispatch(receiveUsernameValidationSuccess());
      } else {
        dispatch(receiveUsernameValidationFailure(result.message));
      }
    } else {
      validate(validator, username, cancellationAmbassador)
        .then(() => {
          memoize(validator, username, true);
          dispatch(receiveUsernameValidationSuccess());
        })
        .catch(reason => {
          if (reason !== "cancellation") {
            memoize(validator, username, err);
            dispatch(receiveUsernameValidationFailure(err.message));
          }
        });
    }
  };
}
Faris
  • 544
  • 2
  • 8
  • Good thing to consider. But that still doesn't provide a safeguard against a previous validation succeeding after a successor. It just reduces the likelihood of collision assuming I can guess the magic number to wait for lambda post processing of dynamo query on an unknown number of records – Steve Buzonas Jun 13 '17 at 06:48
  • Ok i did some searching (got me interested your question) and i updated the answer to what might be a better fit for your case! you could actually combine both methods – Faris Jun 13 '17 at 07:23
  • That would work, thank you. Also, looking in to the debounce suggestion. Your provided snipped passes in an anonymous function which would be a different object created each time, so if I read the implementation correctly it would just delay the call 300ms. – Steve Buzonas Jun 13 '17 at 08:04
  • Oh, I see, it returns a function like bind. – Steve Buzonas Jun 13 '17 at 09:29